C1b 掃描報告 — 任務設定樹、目前 SSP、稽核人員待辦清單(CM-2100)

範圍:21 檔/1,404 行,跨兩個 repo 配對切。套件側 jedi-compliance-audit 19 檔/1,272 行,主專案側 2 檔/132 行。 掃描工具:Claude Code 官方 claude-security plugin,effort low,兩側各跑一次。 套件側基準 commit:eafc7ae511d5(monorepo 主 checkout,工作區有平行 session 的未提交改動)。主專案側基準 commit:d0b69c115970(同上,dirty)。 驗證章:兩份都是 verified。只掃不修(掃描當時紀錄;後續修正見 README)。


1. 一句話結論

卡片預期的形狀成立:任務設定樹從網址到資料庫,整條路只查了「有沒有登入」和「客戶有沒有買這個功能」,沒有任何一步查「你是不是這個專案的人」。 網址上的專案編號收下後完全沒用到,後面那個編號拿到什麼就拿什麼去查。只要查得到,就當作有權限。結果是同一家客戶裡任何一個登入帳號,都能看到任何一個專案的整棵控制項樹,以及每項任務指派給誰(含姓名)。

「目前 SSP」那支是同一種形狀,但只洩漏一個編號。拿到編號之後,其他 SSP 端點各自有 SspPermissionChecker 擋著,所以傷害小。

稽核人員待辦清單不是洞。查詢條件就是「呼叫者本人」,這個身分從登入憑證取,前端改不了。

跨客戶都擋得住,因為底下的專案、輪次、任務相關表都有開資料庫隔離(DEV 實查)。

補充一個前端的實況:這兩支網址目前沒有畫面在叫。任務設定樹原本的畫面已在 FR-114 CM-2047 決定拆除,「目前 SSP」在前端只剩一個沒人呼叫的方法。但後端網址還開著,直接打照樣回資料。


2. 這一棒在檢查什麼

這支套件(稽核流程)自帶三條「只讀」網址:

網址 給誰用 回什麼
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 允許的正規做法。所以要問的不是「路由層有沒有守門」,而是三個問題:

  1. 每一支公開方法有沒有呼叫專案歸屬或角色檢查?
  2. 網址上帶了兩個編號時,有沒有比對「後面那個屬於前面那個」?
  3. 查資料時,除了編號之外,有沒有再限定「屬於某個專案」?

先數守門次數(卡片的三件事之一):

檔案 守門字眼出現次數 對外方法數
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(...),都不是「專案歸屬」檢查。


3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 嚴重度 來源
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 節說明為什麼)。


4. 工具報的兩條(經三人面板投票)

4.1 C1b-1 任何員工都能看任何專案的任務設定樹

現況:🗑️ 已拆除(M12-4,CM-2176,commit 553381efe,1.21.0 出貨)

場景

小王是 A 客戶的一般員工,只參與專案甲。他從分享連結、瀏覽紀錄或別的畫面知道了專案乙的編號,然後直接打:

GET /api/1.0/grc/project/隨便填/ap/<專案乙的編號>/task-setup/tree

回來的是專案乙的名稱、全部納入稽核範圍的控制項與評估目標、每個目標有幾個收證據任務、幾個已指派,還有每位被指派人的 uid 與暱稱,外加內部的工作流程範本和執行編號。專案乙的詳細頁在正常畫面上是擋他的(主專案的專案詳細查詢會限定「負責人、參與者或管理員」),這條網址卻不擋。

為什麼會這樣(整條呼叫鏈都讀過)

  1. 路由 task_setup_route.py:26-31:只掛 @jwt_required() 和 @require_license("project")。收下 project_uid 和 ap_uid 兩個參數,只把 ap_uid 往下傳。get_user_context() 被呼叫了,但回傳值沒有被使用(第 30 行,註解寫「確保有登入」)。
  2. App service task_setup_service.py:18-29:一行守門都沒有,直接轉給 domain service。
  3. Domain service flow_control_task_setup_domain_service.py:9-10:直接轉給 repo。
  4. 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 解出來的專案就是這一個,不是就拒絕。

4.2 C1b-2「目前 SSP」誰都能問

現況:🗑️ 已拆除(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 同一張修正卡順手補,不必另開。


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

5.1 _resolve_ssp 編號三猜:成立,而且把 C1b-1 放大了

flow_control_task_setup_repo_impl.py:247-290,依序把收到的編號當成:

  1. 專案編號(第 258-263 行):查 compliance.projects,回專案的工作中 SSP。
  2. 稽核輪次編號(第 266-275 行):查 project_audit_rounds 連 projects,回該輪的凍結快照,沒有就回母專案的工作中 SSP。
  3. 稽核計畫編號(第 278-288 行):查 oscal.assessment_plans 連輪次連專案。

三條查詢的條件都只有編號,沒有「而且要屬於網址上那個專案」。方法說明(第 46-47 行)寫明這是「為兼容 route 實際傳入的識別子」而刻意做的。後果是攻擊者手上有專案、輪次、稽核計畫任何一個編號都能打,可利用面變成三倍。

另外注意第 ③ 條查的是 oscal.assessment_plans,這張表沒開資料庫隔離(DEV 實查 relrowsecurity = f)。但它後面接的 JOIN compliance.projects 有隔離,所以跨客戶的計畫編號查到這一步會被刷掉,不會跨客戶。修的人不要以為只靠第 ③ 條查詢本身就擋得住。

這跟 FR-113 O1/B2 是同一種形狀:網址帶了兩層編號(父層+子層),程式只拿子層去查,從來不比對「子層屬不屬於父層」。

5.2 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。

結論:此項查證不成立,下次不用重查。

5.3 稽核人員待辦清單(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')。只列「我」是稽核員或管理者的專案。
  • 判定統計那段(第 81-87 行)用 ar_result_id 查 oscal.assessment_findings(這張表沒開隔離)。但 ar_result_id 是從上面那條已限定「我」的查詢取出來的,不是使用者傳的,所以不會越界。
  • status、keyword 都用參數綁定,沒有字串拼接,不會被注入 SQL。

結論:與 FR-095 H1 的預期相同,此項不成立。 主專案側掃描的研究員也追進了套件側路由確認 user_id 的來源,回報零發現;密鑰專項掃描也是零發現。

5.4 「檢查甲、動乙」:本棒沒有

本棒三支都是讀取,沒有 delete_*/update_*。卡片點名的「檢查甲、動乙」要在寫入類方法裡找,所以這一形狀本棒不適用。

project_extension_repo_impl.py 裡有 add、update_owner 兩支寫入方法,條件只有 Project.id。但它們的呼叫者(專案啟動、換負責人)不在本棒範圍,由 C2a/C1 那幾棒追。

5.5 前端到底還有沒有人叫這兩支

  • 任務設定樹:前端 src/views/project/TaskSetupView.vue:109 叫的是 /grc/project/<id>/task-setup/tree,網址形狀已經對不上後端(少了 /ap/<ap_uid> 那段),而且這個畫面在 FR-114 CM-2047 已裁定拆除。專案規劃頁(ProjectPlanningView.vue:706)的註解也寫明舊的樹 API「已 dark」。
  • 目前 SSP:src/service/SspService.js:190 有 getCurrentSspUid,但全前端搜不到呼叫者。

也就是說,正常畫面不會觸發這兩支,但後端網址仍掛著(套件 api/routing.py:31-35,由主專案 api/flow_control/__init__.py 掛上 /api/1.0/grc)。修法有兩個選項:補守門,或者直接拆掉這兩支網址。要拆的話要先確認沒有其他消費者(例如 AI 儀表板或外部整合),這一步本棒沒做。


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

「工具報的兩條存在嗎」:可信度中到高。 套件側三位檢查員共投 6 票全數投出。C1b-1 是 3:0 成立,C1b-2 是 2:1 成立。兩條的嚴重度都被面板從中調降為低。我自己也逐檔讀完整條呼叫鏈,結論一致。

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

  • 資料庫隔離現況:DEV 查 pg_class.relrowsecurity 與 pg_policies(2026-09-24 12:51,唯讀交易,已 ROLLBACK)。compliance 底下七張相關表都有開,oscal.assessment_plans/assessment_findings/system_security_plans/catalog_* 都沒開。
  • 前端呼叫者:開 FE repo 原始碼 grep 確認。
  • SspPermissionChecker 的讀寫兩道檢查:開主專案 common/authz/ssp.py 確認。

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

  1. 用的是最快的檔位(effort low),研究員一輪加投票一輪,沒跑威脅建模與廣度掃描。
  2. 兩側掃描各自看不到對方。第 5.3 節的跨側接縫是我接起來的,主專案側研究員也有自己追過去。
  3. 沒有實際打任何網址。「看得到別人的專案」是讀程式讀出來的,沒有實際打出來。
  4. 讀的是套件 monorepo 主 checkout(feature/review),不是 FR-114 修正分支的 worktree。修正分支上這三支有沒有被動過,本棒沒核對。

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

項目 套件側 主專案側
掃描範圍 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 從哪來。零發現是真的讀完之後的結論,不是空跑。


8. 待首腦裁決

  1. C1b-1+C1b-2+C1b-3 併一張修正卡:在兩支 app service 開頭補專案參與者檢查,並比對「編號解出來的專案=網址上的專案」。或者:
  2. 直接拆掉這兩支網址:前端都已經沒有畫面在叫。拆掉比補守門更徹底,但要先派人確認沒有其他消費者。建議拆,理由是「沒人用的網址,守門補得再好也是多一個要維護的入口」。
  3. M12 總報告那段「那幾條沒有追過」要更新:docs/security-report/M12-compliance-audit.md:110 寫「稽核員待辦清單、任務設定、目前的系統安全計畫查詢,背後的程式一道歸屬檢查都查不到……既不能說有問題,也不能說沒問題」。本棒已追過:任務設定、目前 SSP 屬實有問題(低);稽核員待辦清單沒問題。本棒依紀律不動 docs/security-report/,由首腦決定何時回寫。