C1 掃描報告——接線總裝+審閱簽核

C1 掃描報告 — 接線總裝+審閱簽核(CM-2102)

範圍:22 檔/1,739 行,跨兩個 repo。套件側 jedi-compliance-audit 20 檔(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-security plugin,effort low,只看正式程式碼。兩側各掃一次,因為工具只認它所在那個 repo 的檔案。 掃描基準:套件側 eafc7ae511d5(monorepo 主 checkout,feature/review);主專案側 65620f07e522(feature/review)。兩邊工作區都有其他 session 還沒 commit 的改動。 驗證章:兩次都是 verified。只掃不修(掃描當時紀錄;後續修正見 README)。


1. 一句話結論

這一棒範圍內沒有新的漏洞。 主系統交給套件的三道守門零件,就是主系統真正在用的那三道(沒有替換、沒有吞錯誤、查不到角色一律擋下);卡片最擔心的「審閱簽核的角色檢查,零件沒裝就直接放行」寫法確實存在,但目前整個系統只有一個地方在組裝這支服務,而且兩個零件都有傳,所以現在不會出事。三人面板也對這條投了 0:3 不成立。

但這個寫法本身是一顆未來地雷:套件的「沒接好就拒絕掛載」只檢查網址那一層的三道守門,管不到服務層這兩個零件,哪天有人新開一個組裝點漏傳一個,審閱簽核就會整支變成「只要登入就能按」,而且沒有任何錯誤訊息。這跟總表第 62 項、§7 甲組「有注入才守門要不要整組改」是同一個設計形狀,建議併進那個待裁決策(第 6 節第 1 條)。

主專案側工具報了 5 條、全部 3:0 成立,但全是舊案:它們都在這個 blueprint 掛上去的「任務」那幾支網址(看、改、刪單筆任務、任務清單、強制開始),分別=總表第 45、155、159 項,FR-115 W2/W4/W8 已經報過,淨新增 0。


2. 這一棒在檢查什麼

「稽核」這一大塊功能是一支獨立套件(jedi-compliance-audit),主系統這邊只留一支接線檔。接線檔的工作是把三樣「守門零件」交給套件:

  • 商務授權守門:這家客戶有沒有買「專案」這個模組。
  • 專案角色守門:你在這個專案裡是不是經理(或審閱者、稽核員)。
  • 身分判定:你是不是超級管理員。

套件拿到之後,一邊掛在每條網址上(網址層),一邊綁進服務裡(服務層)。這一棒要回答兩件事:

  1. 套件拿到的三道守門,是不是主系統真正在用的那三道?有沒有哪一道被換成「永遠放行」、或把「查不到角色」當成通過?
  2. 「審閱簽核」這個功能(稽核經理或審閱者在控制項旁邊按「已審閱」打勾)的角色檢查,開頭寫著「兩個零件任一沒裝就直接跳過」。這個寫法現在是不是每一處都有裝?

3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 跟總表的關係
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. 工具報的五條(主專案側,經三人面板投票,全是舊案)

五條是同一個形狀:網址同時帶「專案編號」和「任務編號」,系統只檢查你在網址上那個專案的身分,從來不核對這筆任務是不是屬於那個專案;資料庫的隔離只分客戶、不分專案,所以兩層都擋不住。總表 §4 🅰 組把它記成「有人守了一半」的第八種長相。

  • C1-1/C1-2/C1-3:稽核經理在規劃頁看、改、刪任務。改和刪都有檢查「你是不是網址上那個專案的經理」,但任務是只憑任務編號去找的。所以甲專案的經理把網址填成自己專案、任務編號填乙專案的,就能改掉或刪掉乙的任務;刪除時的「輪次已凍結就不准刪」也是查甲專案,乙凍結了照樣刪得掉。看那一支連經理檢查都沒有,任何登入的人都打得開。=總表第 45 項,修正分支 CM-2039(commit 8e33568d2)已補單筆三支,目前分支上仍開著。
  • C1-4:規劃頁左邊的任務清單。帶一個別專案的編號,把查核項目換成公開的標準條號一條條查,就列得出那個專案所有任務和任務編號,上面三條要的材料就是從這裡拿。=總表第 155 項,修正分支也沒補。
  • C1-5:FR-112 新加的「強制開始」按鈕。同樣只檢查網址專案的經理身分,查任務時沒用網址上的專案編號。=總表第 159 項。

面板三票都評「中」(強制開始那條評「低」),跟總表現在的評等一致,沒有要調整的。


5. 卡片點名的事,逐條回答(runner 自行開檔核對,未經三人面板投票)

5.1 審閱簽核「零件沒裝就跳過檢查」:現在每一處都有裝,但套件的「拒絕掛載」管不到它

先講這段程式在做什麼:審閱簽核按下去時,服務會先檢查「你是不是這個專案的經理或審閱者」(套件側 app/service/review_service.py:32 _require_manager)。這個檢查要兩個零件:一個是「查某人在專案裡是什麼角色」,一個是「用專案編號找專案」。第 41-42 行寫著:兩個零件任一沒裝,就直接 return,等於不檢查、直接放行。

① 是不是每個建審閱服務的地方都有傳這兩個零件?是。

  • 我在主專案、套件主 checkout 兩邊都 grep 了 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。所以不存在「一邊有裝、另一邊少裝」的情況。
  • 審閱標記的 repo 與 domain service 也只在 flow_control_containers.py:331-336 建一次。除了審閱服務,另一個用到它的是稽核輪次服務(:370),它只呼叫「開覆核輪時清掉那幾個控制項的舊勾勾」(clear_marks_for_controls),走的是開輪流程,開輪本身另有守門,不經過 _require_manager。
  • 唯一另外 new 出 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 漏接讓守門靜默失效」同一種病:

  • 哪天有人為了新功能(例如 AI 儀表板、背景工作)另外組裝一支 ReviewService 而漏傳一個零件,審閱簽核就會變成「只要登入、有專案模組授權就能按」。
  • 同一支套件裡已經有更安全的寫法可以照抄:common/guard.py:15-20 的說明原話是「缺守門要吵,不要安靜」。

修法方向(不在本棒做):把第 41-42 行的 return 改成直接報錯(比照 common/guard.py:_require),讓漏裝的症狀是「按下去 500」而不是「誰都能按」。唯一需要注意的是那支測試:它本來就兩個零件都傳,不會受影響。建議併進總表 §7 甲組「有注入才守門要不要整組改」一起裁(第 6 節第 1 條)。

5.2 _resolve_control_ids 收了 ap_uid 沒用:審閱標記寫進去的,就是剛檢查過角色的那個專案

先講清楚兩個編號:網址是 /project/<project_uid>/ap/<ap_uid>/control/<control_uid>/review,project_uid 是專案、ap_uid 是稽核輪次。

逐步對照:

  1. 路由(套件側 api/routes/review_route.py:18-22)把網址上的 project_uid 原樣交給服務。
  2. 服務(review_service.py:85)先拿同一個 project_uid 做角色檢查:用它找專案(:43),再查你在這個專案的角色(:46-49)。
  3. 接著把同一個 project_uid 交給 repo(:86)。repo 的 _resolve_control_ids(infra/repository/flow_control_review_repo_impl.py:25)用 WHERE p.uid = :puid 找專案,再沿著「專案 → 專案的現行 SSP → 它引用的標準 → 標準裡的控制項」這條鏈找控制項(:54-67)。
  4. 寫進資料庫的 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 的範圍、不在本棒,只記下前提成立。

5.3 主專案三支守門 adapter:沒有吞錯誤,也沒有把「查不到角色」當成放行

主專案側 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 的查核一致。

5.4 api/flow_control/__init__.py:接線缺了會吵,不會安靜

  • 第 83-93 行:取不到套件的零件就直接報錯、啟動失敗,不會讓網址安靜消失。
  • 第 93 行呼叫的 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 那邊比照不重報。

5.5 前面各批要求帶著追的三件事

  • 守門次數對對外方法數:審閱服務 4 支對外方法(mark_control、unmark_control、mark_ao、unmark_ao),4 支開頭都呼叫 _require_manager,沒有漏。
  • 檢查甲、動乙:見 5.2。新增與刪除都用同一個專案找控制項和標記;刪除只刪「這個專案、這個控制項、你自己」那一筆(remove_review:192-197 帶了 user_id),不能刪別人的勾勾。AO 也被限定在剛找到的那個控制項底下(_resolve_ao_id:94-99)。
  • 讀取類要問憑什麼給你看:本棒範圍內的審閱讀取,只出現在新增、刪除之後回傳的「這個節點目前有誰勾過」,那時已經過角色檢查。套件側沒有單獨的審閱讀取網址。

5.6 資料庫隔離現況(DEV 唯讀實查)

2026-09-24 15:24,以 cm_app 身分、唯讀交易、查完 ROLLBACK:

  • compliance.review_marks(審閱標記):隔離有開(relrowsecurity = t),4 條規則(讀、新增、修改、刪除)。每一條都靠「這筆標記的 project_id 在你看得到的專案裡」來擋;專案表本身也有開隔離。所以跨客戶擋得住,同客戶跨專案擋不住,要靠程式那一層(5.2 已確認程式有守)。
  • ⚠️ 盤點檔 §5 那一列寫的是「開(4 條,繞專案表)」,跟 DEV 一致。同一列的出貨基線那一欄寫「⚠️ 關(0 條)」,這就是 README 已記的「出貨基線快照沒重產」問題,不歸本棒。

6. 本棒另外查到的(runner 自行開檔核對,未經投票)

6.1 審閱服務另一個「沒裝就靜默」的零件:審閱者名冊

review_service.py:62:名冊(user_directory)沒裝時,勾勾旁邊顯示的是使用者的內部數字編號,而不是暱稱。這不是資安問題(不會多給任何人看東西,顯示的也只是編號),而且主專案 flow_control_containers.py:504 有裝、套件 plugin/wiring.py:60-64 啟動時也會在沒裝時留警告。記下來,只是因為它和 5.1 是同一支服務、同一種「沒裝就安靜」的形狀,修 5.1 時順便決定要不要也改成吵。


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

「工具報的那幾條存在嗎」——可信度高。

  • 套件側:研究員提了 1 條候選(就是 5.1 那段提早 return),三位檢查員 3 票全投、0:3 否決,理由都是「只有組裝的人能決定、而唯一組裝點兩個都傳了」,跟我自己核對的一致。
  • 主專案側:5 條候選 15 票全投,五條都 3:0 成立,嚴重度沒有被調降。

「第 5、6 節」——runner 自行開檔核對,未經三人面板投票。 關鍵事實都有實查:

  • ReviewService 在正式程式碼只有一個組裝點:主專案與套件主 checkout 全文 grep;oscal_containers.py 逐行看過,沒有相關組裝。
  • 套件主 checkout 與本機實際載入的修正分支 worktree(.claude/worktrees/jedi-wt-fix-security/):本棒範圍 7 支關鍵檔逐檔 diff,內容完全相同,所以不管看哪一份,結論都一樣。
  • 查不到角色回 None 再被擋:開套件 participant_role_service.py 與主專案 common/authz/project.py 確認。
  • 資料庫隔離、「沒有兩個專案共用同一份標準」:DEV 唯讀實查(2026-09-24 15:24/15:28)。

「只有這些嗎」——不保證。

  1. 這次用的是最快的掃描檔位(effort low):只有研究員一輪加投票一輪,沒有跑威脅建模與廣度掃描。
  2. 主專案側工具報的五條,出問題的檔案都不在本棒範圍。研究員是順著網址追過去的,那些檔案本身沒有被當成稽核對象逐行讀完(它們已由 FR-115 W2/W4/W8 掃過)。
  3. 全程沒有實際打任何網址。「會被改、會被刪」是讀程式讀出來的,不是實際打出來的。

8. 執行概況(數字,給工程師看)

項目 套件側 主專案側
掃描範圍 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/(未入版控)

密鑰專項兩側都沒有撿到任何東西。


9. 待首腦裁決

  1. 審閱簽核「零件沒裝就放行」要不要改成「沒裝就報錯」(5.1)。建議併進總表 §7 甲組「有注入才守門要不要整組改」一起裁,不另開項次;若要記,歸類為「未來陷阱」,與第 62 項同型。修法只有兩行,現有測試不受影響。
  2. 主專案側五條的淨新增為 0:C1-1/2/3=第 45 項、C1-4=第 155 項、C1-5=第 159 項。只需要在那三項加一句「FR-116 C1 第三次獨立命中」,不另計。
  3. FR-095 H2(CM-1783)派工時,api/flow_control/__init__.py 已由本棒掃過,工具在這支檔上報的全是任務網址的舊案,H2 那邊比照不重報。