範圍:21 檔/1,404 行,跨兩個 repo 配對切。套件側
jedi-compliance-audit19 檔/1,272 行,主專案側 2 檔/132 行。 掃描工具:Claude Code 官方claude-securityplugin,effort low,兩側各跑一次。 套件側基準 commit:eafc7ae511d5(monorepo 主 checkout,工作區有平行 session 的未提交改動)。主專案側基準 commit:d0b69c115970(同上,dirty)。 驗證章:兩份都是 verified。只掃不修(掃描當時紀錄;後續修正見 README)。
卡片預期的形狀成立:任務設定樹從網址到資料庫,整條路只查了「有沒有登入」和「客戶有沒有買這個功能」,沒有任何一步查「你是不是這個專案的人」。 網址上的專案編號收下後完全沒用到,後面那個編號拿到什麼就拿什麼去查。只要查得到,就當作有權限。結果是同一家客戶裡任何一個登入帳號,都能看到任何一個專案的整棵控制項樹,以及每項任務指派給誰(含姓名)。
「目前 SSP」那支是同一種形狀,但只洩漏一個編號。拿到編號之後,其他 SSP 端點各自有 SspPermissionChecker 擋著,所以傷害小。
稽核人員待辦清單不是洞。查詢條件就是「呼叫者本人」,這個身分從登入憑證取,前端改不了。
跨客戶都擋得住,因為底下的專案、輪次、任務相關表都有開資料庫隔離(DEV 實查)。
補充一個前端的實況:這兩支網址目前沒有畫面在叫。任務設定樹原本的畫面已在 FR-114 CM-2047 決定拆除,「目前 SSP」在前端只剩一個沒人呼叫的方法。但後端網址還開著,直接打照樣回資料。
這支套件(稽核流程)自帶三條「只讀」網址:
| 網址 | 給誰用 | 回什麼 |
|---|---|---|
GET /api/1.0/grc/project/<project_uid>/ap/<ap_uid>/task-setup/tree |
任務設定頁 | 整棵「控制群組 → 控制項 → 評估目標」樹,加上每個評估目標底下的收證據任務數、指派人 uid 與暱稱、工作流程編號 |
GET /api/1.0/grc/project/<uid>/current-ssp-uid |
前端從專案找 SSP | 該專案工作中 SSP 的編號與狀態 |
POST /api/1.0/grc/audits/my/list |
「我的稽核任務」 | 呼叫者身為稽核員或管理者的專案裡,進行中的稽核輪次清單與判定統計 |
這支套件的權限守在 app service 層(_check_role、assert_project_role 等),路由層只掛登入與商務授權。這是 CLAUDE.md 允許的正規做法。所以要問的不是「路由層有沒有守門」,而是三個問題:
先數守門次數(卡片的三件事之一):
| 檔案 | 守門字眼出現次數 | 對外方法數 |
|---|---|---|
app/service/task_setup_service.py |
0 | 1 |
app/service/project_current_ssp_service.py |
0 | 1 |
api/routes/task_setup_route.py |
2(都是登入+授權) | 1 |
api/routes/project_current_ssp_route.py |
2(同上) | 1 |
api/routes/auditor_dashboard_route.py |
2(同上) | 1 |
路由層那 2 次是 @jwt_required() 和 @require_license(...),都不是「專案歸屬」檢查。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 | 來源 |
|---|---|---|---|---|---|---|
| C1b-1 | 任務設定樹不查專案歸屬 | 同一家客戶的任何員工能看到任何專案的控制項範圍、任務分派,以及每位被指派人的姓名 | 同客戶的登入帳號+知道目標的專案、輪次或稽核計畫編號其中一個 | 套件側 app/service/task_setup_service.py:18(get_task_setup_tree 方法起點) |
低(面板 3:0 成立,把研究員報的中調降為低) | 套件側掃描 |
| C1b-2 | 「目前 SSP」不查專案歸屬 | 同客戶的非成員能確認某專案存在,並拿到它的 SSP 編號與狀態 | 同客戶的登入帳號+知道專案編號 | 套件側 app/service/project_current_ssp_service.py:35(get 方法起點) |
低(面板 2:1 成立) | 套件側掃描 |
| C1b-3 | 任務設定樹網址上的專案編號收了不用,後面那個編號一次猜三種 | 放大 C1b-1 的可利用面:輪次編號、稽核計畫編號、專案編號,拿到任何一個都能用 | 同 C1b-1 | 套件側 api/routes/task_setup_route.py:28、infra/repository/flow_control_task_setup_repo_impl.py:247(_resolve_ssp) |
併入 C1b-1 修正 | runner 自行開檔(面板理由也提到),未單獨投票 |
主專案側掃描零發現(第 5.3 節說明為什麼)。
現況:🗑️ 已拆除(M12-4,CM-2176,commit 553381efe,1.21.0 出貨)
場景
小王是 A 客戶的一般員工,只參與專案甲。他從分享連結、瀏覽紀錄或別的畫面知道了專案乙的編號,然後直接打:
GET /api/1.0/grc/project/隨便填/ap/<專案乙的編號>/task-setup/tree
回來的是專案乙的名稱、全部納入稽核範圍的控制項與評估目標、每個目標有幾個收證據任務、幾個已指派,還有每位被指派人的 uid 與暱稱,外加內部的工作流程範本和執行編號。專案乙的詳細頁在正常畫面上是擋他的(主專案的專案詳細查詢會限定「負責人、參與者或管理員」),這條網址卻不擋。
為什麼會這樣(整條呼叫鏈都讀過)
task_setup_route.py:26-31:只掛 @jwt_required() 和 @require_license("project")。收下 project_uid 和 ap_uid 兩個參數,只把 ap_uid 往下傳。get_user_context() 被呼叫了,但回傳值沒有被使用(第 30 行,註解寫「確保有登入」)。task_setup_service.py:18-29:一行守門都沒有,直接轉給 domain service。flow_control_task_setup_domain_service.py:9-10:直接轉給 repo。flow_control_task_setup_repo_impl.py:68 呼叫 _resolve_ssp(ap_uid),只依編號找 SSP,找到就開始組樹(第 75-231 行)。require_license 的實作在主專案 common/authz/license.py,只查「這家客戶的授權檔裡有沒有 project 模組」,完全不看使用者是誰、跟專案有什麼關係。
跨客戶為什麼擋得住:樹用到的表 compliance.projects、project_audit_rounds、workflow_templates、workflow_executions、job_executions、task_assignees 在 DEV 都開了資料庫隔離,政策都是「超級管理員,或租戶在允許範圍內」(2026-09-24 12:51 唯讀實查,查完已 ROLLBACK)。所以別家客戶的編號在第一步就查不到,會回一棵空樹。
嚴重度為什麼是低:只能讀、不能改;只限同一家客戶;編號是 UUID,要先從別處取得。但洩漏的內容包括人名與任務分派,比 C1b-2 實在。三位檢查員中有一位評為中。
修法方向:在 TaskSetupService.get_task_setup_tree 的開頭,把網址上的 project_uid 解成專案,用 guards.assert_project_role(允許所有讀取角色)檢查目前使用者是參與者。接著確認 ap_uid 解出來的專案就是這一個,不是就拒絕。
現況:🗑️ 已拆除(M12-5,CM-2176,commit 553381efe,1.21.0 出貨)
場景
同一家客戶的非成員拿專案乙的編號打 GET /api/1.0/grc/project/<專案乙>/current-ssp-uid,拿到專案乙工作中 SSP 的編號與狀態。
為什麼會這樣:project_current_ssp_service.py:35-62 用編號查專案(get_by_uid,只比對編號),查到就回 SSP 編號,中間沒有任何參與者檢查。
為什麼傷害小(卡片點名要確認的):拿到 SSP 編號之後,能用它做什麼要看 SSP 端點自己的守門。主專案的 common/authz/ssp.py:53 SspPermissionChecker 就是做這件事:讀取要 require_participant(是該專案參與者),寫入要 require_manager(是管理者,而且計畫還能編輯)。FR-113 O1 報告說各 SSP 端點有接這道檢查。本棒只開了檢查器本身,沒有逐支 SSP 端點重核,這一點沿用 O1 的結論。所以這支只是洩漏一個隨機編號,本身不是鑰匙。
投反對票的那位檢查員(影響面)理由正是如此:回傳的是隨機 UUID、一個狀態字串、寫死的 ap_uid="" 和 is_editable=true,沒有實質價值。另外兩位認為洩漏本身成立。建議跟 C1b-1 同一張修正卡順手補,不必另開。
_resolve_ssp 編號三猜:成立,而且把 C1b-1 放大了flow_control_task_setup_repo_impl.py:247-290,依序把收到的編號當成:
compliance.projects,回專案的工作中 SSP。project_audit_rounds 連 projects,回該輪的凍結快照,沒有就回母專案的工作中 SSP。oscal.assessment_plans 連輪次連專案。三條查詢的條件都只有編號,沒有「而且要屬於網址上那個專案」。方法說明(第 46-47 行)寫明這是「為兼容 route 實際傳入的識別子」而刻意做的。後果是攻擊者手上有專案、輪次、稽核計畫任何一個編號都能打,可利用面變成三倍。
另外注意第 ③ 條查的是 oscal.assessment_plans,這張表沒開資料庫隔離(DEV 實查 relrowsecurity = f)。但它後面接的 JOIN compliance.projects 有隔離,所以跨客戶的計畫編號查到這一步會被刷掉,不會跨客戶。修的人不要以為只靠第 ③ 條查詢本身就擋得住。
這跟 FR-113 O1/B2 是同一種形狀:網址帶了兩層編號(父層+子層),程式只拿子層去查,從來不比對「子層屬不屬於父層」。
project_extension_repo_impl.get_one_by_fields 用 living_ssp_id/owner_id 查專案:安全project_extension_repo_impl.py:58-88 查的是主表 compliance.projects(第 87 行 self.session.query(Project))。這張表有資料庫隔離,所以跨客戶查不到。這支 repo 是共用的讀寫窗口,本身不該有守門;守門是呼叫者(app service)的責任。本棒範圍裡唯一的呼叫者是 ProjectCurrentSspService,問題在它沒守(C1b-2),不在 repo。
結論:此項查證不成立,下次不用重查。
auditor_dashboard_query):不是洞跨側接縫,要兩邊接起來看:
auditor_dashboard_route.py:31-36:user = get_user_context(),把 user.id 傳進 service。這個 id 來自登入憑證,請求內容改不到它。請求內容只能帶 status 和 keyword 兩個篩選條件。infra/readmodel/audit/auditor_dashboard_query.py:55-58:JOIN project_participants pp ON pp.project_id = r.project_id AND pp.user_id = :uid AND pp.role IN ('auditor','manager')。只列「我」是稽核員或管理者的專案。ar_result_id 查 oscal.assessment_findings(這張表沒開隔離)。但 ar_result_id 是從上面那條已限定「我」的查詢取出來的,不是使用者傳的,所以不會越界。status、keyword 都用參數綁定,沒有字串拼接,不會被注入 SQL。結論:與 FR-095 H1 的預期相同,此項不成立。 主專案側掃描的研究員也追進了套件側路由確認 user_id 的來源,回報零發現;密鑰專項掃描也是零發現。
本棒三支都是讀取,沒有 delete_*/update_*。卡片點名的「檢查甲、動乙」要在寫入類方法裡找,所以這一形狀本棒不適用。
project_extension_repo_impl.py 裡有 add、update_owner 兩支寫入方法,條件只有 Project.id。但它們的呼叫者(專案啟動、換負責人)不在本棒範圍,由 C2a/C1 那幾棒追。
src/views/project/TaskSetupView.vue:109 叫的是 /grc/project/<id>/task-setup/tree,網址形狀已經對不上後端(少了 /ap/<ap_uid> 那段),而且這個畫面在 FR-114 CM-2047 已裁定拆除。專案規劃頁(ProjectPlanningView.vue:706)的註解也寫明舊的樹 API「已 dark」。src/service/SspService.js:190 有 getCurrentSspUid,但全前端搜不到呼叫者。也就是說,正常畫面不會觸發這兩支,但後端網址仍掛著(套件 api/routing.py:31-35,由主專案 api/flow_control/__init__.py 掛上 /api/1.0/grc)。修法有兩個選項:補守門,或者直接拆掉這兩支網址。要拆的話要先確認沒有其他消費者(例如 AI 儀表板或外部整合),這一步本棒沒做。
「工具報的兩條存在嗎」:可信度中到高。 套件側三位檢查員共投 6 票全數投出。C1b-1 是 3:0 成立,C1b-2 是 2:1 成立。兩條的嚴重度都被面板從中調降為低。我自己也逐檔讀完整條呼叫鏈,結論一致。
「第 5 節」:runner 自行開檔核對,未經三人面板投票。 關鍵事實有實查:
pg_class.relrowsecurity 與 pg_policies(2026-09-24 12:51,唯讀交易,已 ROLLBACK)。compliance 底下七張相關表都有開,oscal.assessment_plans/assessment_findings/system_security_plans/catalog_* 都沒開。SspPermissionChecker 的讀寫兩道檢查:開主專案 common/authz/ssp.py 確認。「只有這些嗎」:不保證。
feature/review),不是 FR-114 修正分支的 worktree。修正分支上這三支有沒有被動過,本棒沒核對。| 項目 | 套件側 | 主專案側 |
|---|---|---|
| 掃描範圍 | 19 檔/1,272 行 | 2 檔/132 行 |
| 基準 commit | eafc7ae511d5(dirty) |
d0b69c115970(dirty) |
| 檔位 | effort low,不設 focus | effort low,focus 生產程式碼 |
| 研究員 | 派 1、回 1 | 派 2(研究+密鑰專項)、回 2 |
| 原始候選 | 2 條,去重後 2 條 | 0 條 |
| 投票 | 3 × 2 = 6 票,全數投出 | 無候選,不投票 |
| 票型 | C1b-1 3:0(低、低、中);C1b-2 2:1(低、低;影響面投不成立) | — |
| 驗證章 | verified(CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json) |
verified(CLAUDE-SECURITY-REVISION-d0b69c115970-dirty.json) |
| 工具 run ID | wf_0dd778dd-464 |
wf_971a29f5-350 |
| 耗時 | 約 12 分鐘(7 個 agent,零失敗) | 約 31 秒(2 個 agent,零失敗) |
| 工具原始報告 | 套件 repo CLAUDE-SECURITY-20260924-044632/(未入版控) |
BE repo CLAUDE-SECURITY-20260924-050436/(未入版控) |
主專案側的研究員只用了 12 次工具呼叫就交零發現,時間很短,所以我開了它的逐步紀錄確認:兩支範圍檔都讀了,也追進了套件側路由看 user_id 從哪來。零發現是真的讀完之後的結論,不是空跑。
docs/security-report/M12-compliance-audit.md:110 寫「稽核員待辦清單、任務設定、目前的系統安全計畫查詢,背後的程式一道歸屬檢查都查不到……既不能說有問題,也不能說沒問題」。本棒已追過:任務設定、目前 SSP 屬實有問題(低);稽核員待辦清單沒問題。本棒依紀律不動 docs/security-report/,由首腦決定何時回寫。