範圍:22 檔/1,739 行,跨兩個 repo。套件側
jedi-compliance-audit20 檔(plugin/組裝五支、api/guards.py、common/guard.py、api/routing.py,以及審閱簽核那一條直線:路由、服務、DTO、entity、repo 介面與實作、domain service、model);主專案側 2 檔(core/plugins/compliance_audit.py、api/flow_control/__init__.py)。 掃描工具:Claude Code 官方claude-securityplugin,effort low,只看正式程式碼。兩側各掃一次,因為工具只認它所在那個 repo 的檔案。 掃描基準:套件側eafc7ae511d5(monorepo 主 checkout,feature/review);主專案側65620f07e522(feature/review)。兩邊工作區都有其他 session 還沒 commit 的改動。 驗證章:兩次都是 verified。只掃不修(掃描當時紀錄;後續修正見 README)。
這一棒範圍內沒有新的漏洞。 主系統交給套件的三道守門零件,就是主系統真正在用的那三道(沒有替換、沒有吞錯誤、查不到角色一律擋下);卡片最擔心的「審閱簽核的角色檢查,零件沒裝就直接放行」寫法確實存在,但目前整個系統只有一個地方在組裝這支服務,而且兩個零件都有傳,所以現在不會出事。三人面板也對這條投了 0:3 不成立。
但這個寫法本身是一顆未來地雷:套件的「沒接好就拒絕掛載」只檢查網址那一層的三道守門,管不到服務層這兩個零件,哪天有人新開一個組裝點漏傳一個,審閱簽核就會整支變成「只要登入就能按」,而且沒有任何錯誤訊息。這跟總表第 62 項、§7 甲組「有注入才守門要不要整組改」是同一個設計形狀,建議併進那個待裁決策(第 6 節第 1 條)。
主專案側工具報了 5 條、全部 3:0 成立,但全是舊案:它們都在這個 blueprint 掛上去的「任務」那幾支網址(看、改、刪單筆任務、任務清單、強制開始),分別=總表第 45、155、159 項,FR-115 W2/W4/W8 已經報過,淨新增 0。
「稽核」這一大塊功能是一支獨立套件(jedi-compliance-audit),主系統這邊只留一支接線檔。接線檔的工作是把三樣「守門零件」交給套件:
套件拿到之後,一邊掛在每條網址上(網址層),一邊綁進服務裡(服務層)。這一棒要回答兩件事:
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 跟總表的關係 |
|---|---|---|---|---|---|
| C1-1 | 甲專案的經理可以改乙專案的任務 | 改掉別專案任務的名稱、說明、設備、檢測目標,還能把自己加成指派人 | 同一家客戶、自己是任一專案的經理、知道任務編號 | 主專案側 app/flow_control/service/job_service.py:308 update_job 開頭 |
=第 45 項(SUMMARY #63),不另計 |
| C1-2 | 甲專案的經理可以刪乙專案的任務,對方輪次凍結了也刪得掉 | 別專案的任務連同指派、問卷快照、流程節點一起刪掉 | 同上,另外自己專案的輪次還在規劃期 | 主專案側 job_service.py:214 delete_job 開頭 |
=第 45 項(凍結檢查查錯專案那點 FR-115 已補記),不另計 |
| C1-3 | 任何人都打得開別專案任務的詳細內容 | 讀到指派人、部門、設備、檢測工具設定(帳密有遮) | 同一家客戶的登入帳號、知道任務編號 | 主專案側 api/flow_control/routes/job_route.py:88(服務 get_job) |
=第 45 項,不另計 |
| C1-4 | 任務清單不問你是不是這個專案的人 | 列出別專案每個查核項目底下的任務,也拿到上面三條要用的任務編號 | 同一家客戶的登入帳號、知道專案編號 | 主專案側 job_route.py:69(服務 list_jobs) |
=第 155 項,不另計 |
| C1-5 | 甲專案的經理可以把乙專案卡住的任務強制開始 | 別專案卡在合流的任務被推成進行中,稽核事件記在甲專案名下 | 自己是任一專案的經理、知道一張卡在合流的任務編號 | 主專案側 app/flow_engine/service/job_force_start_service.py:82 force_start(實際該補在 _load) |
=第 159 項,不另計 |
以上五條都是主專案側那次掃描、經三人面板投票的;它們掛在本棒範圍 api/flow_control/__init__.py 組裝出來的網址上,但實際出問題的檔案不在本棒範圍。第 5 節是卡片點名的事,由我自己開檔核對,沒有經過投票。
五條是同一個形狀:網址同時帶「專案編號」和「任務編號」,系統只檢查你在網址上那個專案的身分,從來不核對這筆任務是不是屬於那個專案;資料庫的隔離只分客戶、不分專案,所以兩層都擋不住。總表 §4 🅰 組把它記成「有人守了一半」的第八種長相。
8e33568d2)已補單筆三支,目前分支上仍開著。面板三票都評「中」(強制開始那條評「低」),跟總表現在的評等一致,沒有要調整的。
先講這段程式在做什麼:審閱簽核按下去時,服務會先檢查「你是不是這個專案的經理或審閱者」(套件側 app/service/review_service.py:32 _require_manager)。這個檢查要兩個零件:一個是「查某人在專案裡是什麼角色」,一個是「用專案編號找專案」。第 41-42 行寫著:兩個零件任一沒裝,就直接 return,等於不檢查、直接放行。
① 是不是每個建審閱服務的地方都有傳這兩個零件?是。
ReviewService,正式程式碼只有一個地方在組裝它:主專案 di_containers/flow_control/flow_control_containers.py:500-507。兩個零件都有傳(participant_role_service、project_domain_service)。di_containers/oscal/oscal_containers.py:這支檔完全沒有組裝 ReviewService,也沒有組裝審閱標記的 repo 或 domain service。所以不存在「一邊有裝、另一邊少裝」的情況。flow_control_containers.py:331-336 建一次。除了審閱服務,另一個用到它的是稽核輪次服務(:370),它只呼叫「開覆核輪時清掉那幾個控制項的舊勾勾」(clear_marks_for_controls),走的是開輪流程,開輪本身另有守門,不經過 _require_manager。ReviewService 的是測試檔 test/test_fr048_grc_guards.py:31,兩個零件用假物件傳入,不影響正式環境。② 套件的「沒接好就拒絕掛載」管不管得到這兩個零件?管不到。
plugin/assembly.py:18-34 的 _assert_wiring 只檢查 license_guard、project_role_guard、identity_guard 三道網址層的守門有沒有交進來。build_services()),服務是主系統的 DI 容器建的,套件只拿到一個「呼叫就給你一支新服務」的入口(plugin/runtime.py:29-41)。所以掛載時套件根本看不到這支服務有沒有裝零件。common/guard.py:39-45 的 _require 沒接線就直接報錯,不會放行。會放行的只有 review_service.py:41-42 這一段自己寫的提早 return。結論:現在不會出事,三人面板也 0:3 否決(理由同上:只有組裝的人決定要不要傳,發請求的人改變不了,而唯一的組裝點兩個都傳了)。但這是「漏裝一條、整支靜默全開」的形狀,與總表第 62 項同型,也和卡片提到的 FR-113 O9b「DI 漏接讓守門靜默失效」同一種病:
ReviewService 而漏傳一個零件,審閱簽核就會變成「只要登入、有專案模組授權就能按」。common/guard.py:15-20 的說明原話是「缺守門要吵,不要安靜」。修法方向(不在本棒做):把第 41-42 行的 return 改成直接報錯(比照 common/guard.py:_require),讓漏裝的症狀是「按下去 500」而不是「誰都能按」。唯一需要注意的是那支測試:它本來就兩個零件都傳,不會受影響。建議併進總表 §7 甲組「有注入才守門要不要整組改」一起裁(第 6 節第 1 條)。
_resolve_control_ids 收了 ap_uid 沒用:審閱標記寫進去的,就是剛檢查過角色的那個專案先講清楚兩個編號:網址是 /project/<project_uid>/ap/<ap_uid>/control/<control_uid>/review,project_uid 是專案、ap_uid 是稽核輪次。
逐步對照:
api/routes/review_route.py:18-22)把網址上的 project_uid 原樣交給服務。review_service.py:85)先拿同一個 project_uid 做角色檢查:用它找專案(:43),再查你在這個專案的角色(:46-49)。project_uid 交給 repo(:86)。repo 的 _resolve_control_ids(infra/repository/flow_control_review_repo_impl.py:25)用 WHERE p.uid = :puid 找專案,再沿著「專案 → 專案的現行 SSP → 它引用的標準 → 標準裡的控制項」這條鏈找控制項(:54-67)。project_id 就是這條查詢從 p.uid = project_uid 找出來的那個專案(:146、:161-170)。所以角色檢查和寫入用的是同一個專案,沒有「檢查甲、動手改乙」的空隙。 控制項也被限定在「這個專案現行 SSP 引用的那份標準」底下,填別專案的控制項編號會查不到、回 404。
ap_uid 沒用到,是功能上的刻意取捨,不是資安問題::43-48 的說明寫得很清楚,審閱標記是專案層級的紀錄、沒有輪次維度,規劃期還沒有稽核計畫時也要能用。代價只有一個:網址上的 ap_uid 可以亂填,不影響結果。這不會讓人碰到別的專案,此項查證不成立,下次不用重查。
順帶核對讀取那一端:審閱勾勾在控制樹上顯示時,是由主專案側 infra/readmodel/oscal/ssp_control_implementation_query.py:229-237 只用控制項編號查,沒有帶專案條件。這個寫法之所以安全,前提是「每個專案的標準都是自己複製一份,控制項編號不會跨專案共用」(主專案 app/oscal/service/ssp_control_implementation_service.py:732-733 的說明這樣寫)。我在 DEV 唯讀驗證了這個前提:沒有任何兩個專案共用同一份標準(2026-09-24 15:28,查詢結果 0 組)。另外,DEV 審閱標記表目前是 0 筆。這支讀取在 FR-113 的範圍、不在本棒,只記下前提成立。
主專案側 core/plugins/compliance_audit.py 的三支 adapter,逐支對照:
| adapter | 做了什麼 | 有沒有吞錯誤 | 查不到時怎樣 |
|---|---|---|---|
LicenseGuard(:59-70) |
直接轉呼叫 common.authz 的 require_license、viewer_licensed_modules |
沒有 try |
由 common.authz 決定,照主系統一般規則 |
ProjectRoleGuard(:73-92) |
直接轉呼叫 common/authz/project.py 的兩支 |
沒有 try |
見下 |
IdentityGuard(:95-101) |
直接轉呼叫 viewer_is_super_admin |
沒有 try |
由 common.authz 決定 |
「查不到角色」這件事,重點在審閱簽核用的那支 assert_project_role_fallback(主專案側 common/authz/project.py:33-57):它向下呼叫角色查詢,查不到時角色查詢回傳 None(套件 jedi-task-platform 的 participant_role_service.py:34-83,依序查控制項、群組、專案三層,都沒有就回 None);而 None 不在「經理、審閱者」名單裡,第 56-57 行直接擋下、回 403。查不到=擋下,不是放行。
參數位置也核對過了:兩支函式的參數順序本來就不同(fallback 版沒有 user_id),套件 common/guard.py:58-66 → adapter :87-92 → common/authz/project.py:33-36 三層逐個位置一致,沒有錯位。
結論:三支 adapter 就是主系統真正在用的那三道守門,只是單純轉手。此項查證不成立。 這個結論與 FR-095 H1 對 participant.py 的查核一致。
api/flow_control/__init__.py:接線缺了會吵,不會安靜attach(套件側 plugin/assembly.py:50-74)依序做四件事:先驗三道守門(_assert_wiring)、把守門綁進服務層(bind_service_guards)、檢查兩張名冊、最後才掛網址。守門缺一道就不會掛。api/guards.py:45-72)取不到零件也是報錯,不放行。審閱的四支網址(review_route.py)每一支都掛了登入檢查和「專案」模組授權檢查。⚠️ 這支檔同時在 FR-095 H2(CM-1783,未派)的範圍,本棒先掃到。工具在這支檔上報的五條全是它掛上去的「任務」網址的舊案(第 4 節),H2 那邊比照不重報。
mark_control、unmark_control、mark_ao、unmark_ao),4 支開頭都呼叫 _require_manager,沒有漏。remove_review:192-197 帶了 user_id),不能刪別人的勾勾。AO 也被限定在剛找到的那個控制項底下(_resolve_ao_id:94-99)。2026-09-24 15:24,以 cm_app 身分、唯讀交易、查完 ROLLBACK:
compliance.review_marks(審閱標記):隔離有開(relrowsecurity = t),4 條規則(讀、新增、修改、刪除)。每一條都靠「這筆標記的 project_id 在你看得到的專案裡」來擋;專案表本身也有開隔離。所以跨客戶擋得住,同客戶跨專案擋不住,要靠程式那一層(5.2 已確認程式有守)。review_service.py:62:名冊(user_directory)沒裝時,勾勾旁邊顯示的是使用者的內部數字編號,而不是暱稱。這不是資安問題(不會多給任何人看東西,顯示的也只是編號),而且主專案 flow_control_containers.py:504 有裝、套件 plugin/wiring.py:60-64 啟動時也會在沒裝時留警告。記下來,只是因為它和 5.1 是同一支服務、同一種「沒裝就安靜」的形狀,修 5.1 時順便決定要不要也改成吵。
「工具報的那幾條存在嗎」——可信度高。
「第 5、6 節」——runner 自行開檔核對,未經三人面板投票。 關鍵事實都有實查:
ReviewService 在正式程式碼只有一個組裝點:主專案與套件主 checkout 全文 grep;oscal_containers.py 逐行看過,沒有相關組裝。.claude/worktrees/jedi-wt-fix-security/):本棒範圍 7 支關鍵檔逐檔 diff,內容完全相同,所以不管看哪一份,結論都一樣。None 再被擋:開套件 participant_role_service.py 與主專案 common/authz/project.py 確認。「只有這些嗎」——不保證。
| 項目 | 套件側 | 主專案側 |
|---|---|---|
| 掃描範圍 | 20 檔/1,419 行 | 2 檔/320 行 |
| 基準 commit | eafc7ae511d5(dirty) |
65620f07e522(dirty) |
| 檔位 | effort low,focus 生產程式碼 | 同左 |
| 研究員 | 派 2 支、回 2 支(含密鑰專項) | 同左 |
| 原始候選 | 1 條 | 5 條 |
| 投票 | 3 票全投 | 15 票全投 |
| 票型 | C1(提早 return)0:3 否決 | 五條皆 3:0 |
| 驗證章 | verified(CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json) |
verified(CLAUDE-SECURITY-REVISION-65620f07e522-dirty.json) |
| 工具 run ID | wf_2345347c-77d |
wf_592f3f10-fbe |
| 耗時 | 約 4 分鐘(5 個 agent,零失敗,1 個回傳空結果) | 約 17 分鐘(17 個 agent,零失敗,1 個回傳空結果) |
| 工具產出原始報告 | 套件 repo CLAUDE-SECURITY-20260924-071503/(未入版控) |
BE repo CLAUDE-SECURITY-20260924-072304/(未入版控) |
密鑰專項兩側都沒有撿到任何東西。
api/flow_control/__init__.py 已由本棒掃過,工具在這支檔上報的全是任務網址的舊案,H2 那邊比照不重報。