---
title: H1 檢查結果：主專案接上「系統設定核心」的那一段
---

# H1 檢查結果：主專案接上「系統設定核心」的那一段

> 檢查日期 2026-09-15｜耗時 13 分 10 秒｜對應卡片 CM-1805｜檢查範圍 25 個檔案

---

## 🔴 一句話結論

**只有一件新問題，但它很嚴重：一個客戶公司的系統管理員，可以改掉「全部客戶＋我們自己」共用的登入規則——甚至可以把全體員工的登入驗證來源指到他自己的機器上。** 另外掃到的那一條不是新的，是上一棒就已經登記過的同一件事（總表第 72 項），只是這次從另一側又撞到一次。

---

## 這一棒在檢查什麼

系統裡有一塊「系統設定」的功能：管理員在畫面上調整寄信伺服器、登入規則（要不要雙因子、密碼打錯幾次鎖定）、檔案要存去哪裡、要不要接公司的帳號目錄（LDAP）等等。這塊功能的**內臟**放在一個共用套件裡（jedi-system-core），而**主專案**負責把它接到產品上——開出 API、掛上權限檢查、決定哪些欄位要遮起來不給看。

**這一棒檢查的就是「接線」這一段的 25 個檔案**：誰能讀這些設定、誰能改這些設定、改下去會影響到誰。

> 上一棒（P1）檢查的是套件內臟那一半（24 個檔），已經驗收完，找到 2 條。這一棒是同一個功能的另一半。

---

## 掃到什麼：總覽表

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 狀態 |
|---|---|---|---|---|---|---|
| **F2** | 🔴 **高** | **一個客戶公司的管理員，改到的不是自己公司的登入規則，而是「所有人共用的那一份」** | 他可以幫**全部署的每一個人**（包含我們自己的平台管理員）關掉雙因子驗證、把「密碼打錯幾次就鎖定」改成永不鎖定、把登入憑證有效期從 5 分鐘延長到 1 個月 | ① 任一客戶公司的系統管理員帳號<br>② 該帳號**不必特別申請**——新客戶開通時系統就自動發這個權限 | 端點 `api/system_config/routes/security_policy_route.py:31`（PUT 在 `:59`）<br>寫入 `app/system_config/service/security_policy_app_service.py:162`<br>繞隔離 `infra/system_config/system_config_root_reader.py:136` | 🆕 **新增** |
| **F2-B** | 🔴 **高<br>（比 F2 更該先修）** | **同樣的手法，可以把「全公司登入時去哪裡驗證帳號密碼」（LDAP 伺服器位址）改成攻擊者自己的機器** | 登入規則被改壞，還看得出來；**驗證來源被掉包，等於直接接管所有人的登入**——員工照常輸入帳密，帳密卻是送到攻擊者的機器上 | 同 F2（`ldap-config.update`／`smtp-config.update` 這兩個權限一樣自動發給客戶管理員） | `app/system_config/service/guarded_system_config_service.py:104` `_write_shared_root_config()` | 🆕 **新增**（與 F2 同一條記，修法相同） |
| **F1** | 🔴 高 | **讀取系統設定的那支 API 忘了檢查權限**，任何登入者都讀得到檔案儲存的帳號密碼 | 拿得到檔案倉庫的帳密就能繞過系統，直接讀走／改掉／刪掉所有上傳的證據檔與文件 | 任何一個能登入的帳號（不需要任何權限） | `api/system_config/routes/system_config_route.py:131` | ♻️ **不是新的**——就是跨 arc 總表已登記的**第 72 項**（上一棒 P1 查到的）。這次帶回兩個新細節，補進第 72 項 |

**淨結果：工具報 2 條，扣掉 1 條重複，真正新增 1 條（高風險）。問題沒有變多，但新增的這一條很重。**

---

## 每條發現的詳述

### F2：客戶的管理員改到的是「全公司共用的那一份」登入規則（新增）

**現況**：已修（M22-1，CM-2054）；後續「誰算總部」判斷的缺口另由 M22-4 修正（CM-2202，1.21.0 出貨）

#### 白話說明

系統裡有一份「登入安全政策」——要不要雙因子驗證、密碼打錯幾次鎖帳號、鎖多久、登入憑證多久過期。直覺上每個客戶公司應該各有一份、各自調整。

**但實際上整套系統只有一份，而且每個客戶公司的系統管理員都改得動它。**

他打「更新登入安全政策」這支 API，程式確實有檢查「你有沒有改登入政策的權限」——他有。但接下來寫入的時候，程式**刻意關掉了「每個客戶只能碰自己資料」的資料庫隔離機制**，把值寫進總部那一份。於是：

- 把「要雙因子驗證」關掉 → **所有人**（包含我們的平台管理員）登入都不用第二道驗證
- 把「密碼打錯幾次鎖定」改成 9999 → 等於**永不鎖定**，可以慢慢猜密碼
- 把登入憑證有效期從 5 分鐘改成 30 天 → 憑證被偷走之後，攻擊者可以用一個月

#### 在哪裡

| 角色 | 檔案:行號 | 這一行在做什麼 |
|---|---|---|
| 端點守門 | `api/system_config/routes/security_policy_route.py:31` | `_update = require_capability("security-policy.update")`——只檢查「有沒有這個權限」，沒有檢查「是不是平台管理員」 |
| 更新入口 | `api/system_config/routes/security_policy_route.py:59` | PUT 方法（外層 `:57` 只要求已登入） |
| 實際寫入 | `app/system_config/service/security_policy_app_service.py:162` | `update_policy()` |
| 繞過客戶隔離 | `infra/system_config/system_config_root_reader.py:136` | `write_root_config_value()`；其中 `:179` 明文寫 `SET LOCAL app.is_super_admin = 't'`（關掉隔離），並把寫入對象**釘死成總部那一列** |

#### 首腦實查：檢查員答不出來的那一題

三位檢查員一致認為這條成立，但把「可信度」停在 **medium（中等）**，卡在一個他們光讀程式碼答不出來的問題：**「新開的客戶，預設的管理員角色到底有沒有拿到這個權限？」** 如果沒拿到，這條就打不出來。

**首腦到開發環境的資料庫唯讀查了，答案是「有，而且是自動拿到的」：**

**① 誰實際持有這個權限**——`security-policy.update` 這個權限被標成「非平台層」，9 個客戶公司裡有 **8 個非總部公司的管理員角色實際持有它**：

| 客戶公司 | 持有的角色 |
|---|---|
| 1 Guidant.AI | Administrator |
| 102 Billows Tech | Billows Admin |
| 131 歐洲航空 | 歐洲航空系統管理員 |
| 152 租戶A／153 租戶B／158 JEDI／162 租戶C／163 租戶D／164 租戶E | System Manager |

**② 全域那一份真的只有一份**——開發環境的登入政策設定共 **7 筆，全部屬於總部（編號 1）**：是否要雙因子、登入鎖定次數、鎖定時間、憑證有效期、憑證續期有效期、密碼多久要換、多久沒登入算閒置。**「一份供全體使用」屬實。**

**③ 那一份真的被全站吃下去**——`core/app_factory.py:148` 服務啟動時就把它讀進全域設定；`core/plugins/identity.py:141` 判斷「這次登入要不要雙因子」時直接讀它。

**④ 為什麼每個新客戶都會自動拿到（根因）**——`jedi_iam/app/service/tenant_provisioning_service.py:120-131`：新客戶開通時，系統把「所有權限」扣掉標記為「平台層」的那些之後，**其餘全部發給該客戶的預設管理員角色**。所以任何被標成「非平台層」的權限，**新客戶一開通就自動有**，不需要有人去手動指派。

→ **檢查員唯一不確定的前提，在開發環境成立。首腦把可信度從 medium 調到 high（高），嚴重度維持「高風險」。**

---

### F2-B：同樣手法可以掉包「全公司的登入驗證來源」（新增，比 F2 更該先修）

**現況**：已修（M22-1，CM-2054，與 F2 同一套修法）

這一半工具只順帶提了一句，但首腦查證後認為**必須獨立講，而且應該排在 F2 前面修**。

#### 白話說明

很多公司的員工帳號不是存在我們系統裡，而是存在公司自己的帳號目錄伺服器（LDAP）。員工登入時，我們的系統會**拿他輸入的帳號密碼去問那台伺服器「這個人對不對」**。

「那台伺服器在哪裡」這個設定，跟 F2 一樣是**全體共用一份**，而且改它的權限（`ldap-config.update`）同樣被標成「非平台層」，同樣自動發給那 8 個客戶公司的管理員。寄信伺服器設定（`smtp-config.update`）也是一樣的形狀。

**意思是：一個客戶公司的管理員，可以把「全公司登入時去哪裡驗證帳密」指到他自己架的機器上。** 之後所有人照常輸入帳號密碼，帳密卻是送到攻擊者手上；他也可以讓自己的機器對任何帳密都回答「對」，直接以任何人的身分登入。

#### 為什麼它比 F2 更急

**登入規則被改壞，事後看得出來**（大家會發現雙因子不見了）；**驗證來源被掉包，是無聲的接管**——畫面完全正常，員工不會察覺，而攻擊者拿到的是全公司的明文帳號密碼。

#### 在哪裡

- 寫入路徑：`app/system_config/service/guarded_system_config_service.py:104` `_write_shared_root_config()`
- 落點與 F2 相同：總部那一列
- 權限：`ldap-config.update`、`smtp-config.update`，兩者同樣是「非平台層」，同樣發給那 8 個客戶公司（首腦同一次查詢確認）

---

### F2／F2-B 怎麼修（三個方向，需要決策者選）

**現況**：已依決策者裁定修正（M22-1，CM-2054）；以下為掃描當時列的三個方向

好消息是**不用造新東西**——「只有平台管理員才能做」這道守門**已經存在、現成可用**：`common/authz` 對外提供的 `require_platform_admin_route`（實作在 `jedi_iam/authz/decorators.py:29` 與 `jedi_iam/authz/platform.py:25`）。

| 方向 | 做法 | 差別 |
|---|---|---|
| **1. 加掛平台管理員守門** | 在登入政策與共用設定（LDAP／寄信）的**寫入路徑**上，除了現有的權限檢查，再加一道「必須是平台管理員」 | **最直接**，改動最小 |
| **2. 限縮寫入對象** | 不再一律寫進總部那一列，改成「只寫呼叫者自己公司的那一列」，只有總部公司才能編輯總部那一列 | 保留客戶自己調整的能力，但要處理「客戶那一列目前不存在」的情況 |
| **3. 把這三個權限改標成「平台層」** | 把 `security-policy.update`、`ldap-config.update`、`smtp-config.update` 標記為平台層 | **治本**——改標之後新客戶不會再拿到；但**已經發給 8 個客戶的授權仍然存在**，要另外清掉 |

⚠️ **三個方向都要決策者拍板**，因為它們會改變一件產品行為：**「客戶自己到底能不能調整自家的登入強度」**。這不是純技術選擇。

---

### F1：讀取設定的 API 忘了檢查權限（♻️ **這條不是新增的**）

**現況**：已修（M22-2，CM-2063）

#### 🔴 先講清楚：這條不是新問題

**這條就是跨 arc 總表第 72 項，上一棒（P1）就已經登記過的同一件事。** 第 72 項原文已經明白列出三個入口，其中就包含主專案的 `api/system_config/routes/system_config_route.py:131`。H1 只是從主專案這一側再撞到同一件事。**問題沒有變多，不要重複計數、不要重複開修正卡。**

#### 這是什麼問題

「讀取某一組系統設定」的那支 API，**只檢查「你有沒有登入」，沒有檢查「你有沒有權限看這個」**。任何一個能登入的帳號——包括權限最低、完全不該碰設定的專案成員——都能讀出檔案儲存的位址、桶名、帳號與密鑰。

而這組帳密是**安裝程式寫進去的、這台機器真正的檔案倉庫憑證**，而且新客戶開通時會原封不動複製一份。拿到之後可以**繞過整個系統**，直接讀走、覆寫或刪掉倉庫裡所有上傳的證據檔、SSP 文件與檢測報告。

#### 在哪裡（含一處行號更正）

`api/system_config/routes/system_config_route.py`：

```
第 131 行  讀取 get(self, group, key)   只有「要登入」            ← 沒有檢查權限
第 146 行  更新 put(...)                有檢查「更新設定」的權限
第 158 行  刪除 delete(...)             有檢查「刪除設定」的權限

同一支檔案裡的「還原出廠設定」功能：
第 103 行  讀取 GET                     有檢查「讀取儲存設定」的權限   ← 連唯讀的都有
第 111 行  執行 POST                    有檢查「更新」的權限
```

**同一支檔案裡，唯獨這一支讀取沒有權限檢查**，寫入的三支都有，連隔壁那支唯讀的「還原出廠設定」都有。**這證明是漏掉，不是刻意設計。**

> ⚠️ **行號更正**：工具原始報告寫 `:134`，那是函式內部呼叫服務的那一行；**真正缺守門的是 `:131` 的函式本身**。本報告與總表第 72 項一律寫 `:131`，開工單時兩份文件才不會各指一處。

#### H1 帶回兩個第 72 項沒有的新細節（要補進第 72 項）

**新細節 1：同一個沒守門的入口，吐出來的不只是檔案倉庫帳密。**
還包括**登入安全政策**（`PASSWORD_POLICY/CONFIG`）與**閒置逾時設定**（`WEB_IDEL_CONFIG`）——等於把「這套系統的登入防護設定成什麼樣」整份攤給任何登入者看，是攻擊前的偵查材料。

**新細節 2：遮罩只遮掉「密碼」一個欄位，其餘照樣外洩。**
系統有一份「要遮起來不給看」的名單，但它只遮掉欄位名叫 `secret` 或 `private_token` 的值。所以同一列裡**沒被遮的欄位一併外洩**：

- 寄信伺服器的**位址、埠號、帳號、寄件人**
- LDAP 的**位址、埠號、查詢起點、帳號、憑證內容**

名單本身也有缺口——`jedi_system_core/plugin/contract.py` 的遮罩群組名單是 `SMTP`、`THIRD_PARTY_LOGIN`、`NOTIFY_CONFIG`、`ISSUE_INTEGRATE_CONFIG`，**沒有檔案儲存設定**；遮罩欄位名單是 `secret`、`private_token`，**沒有物件儲存用的 `access_key`／`secret_key`**。（主專案端 `api/system_config/routes/system_config_route.py:81` 的 `_MASK_CONFIG` 與 `:84-86` 的 `_mask()` 只是委派套件那份機制。）

#### 怎麼修

修法隨第 72 項辦理，重點兩件事：

1. 在 `api/system_config/routes/system_config_route.py:131` 的 `get()` 第一行補上 `assert_config_capability(group, "read")`，與同檔的更新、刪除一致。
2. **獨立地**把檔案儲存設定加進遮罩群組名單、把 `access_key`／`secret_key`／`minio_access_key`／`minio_secret_key` 加進遮罩欄位名單——**這樣就算未來又漏一次守門，也不會直接變成帳密外洩。**

---

## 這份結果可信到什麼程度

### 「這兩條是真的嗎」→ ✅ 可信

- **三位獨立檢查員對兩條發現各投一票，6 票全數投出，兩條都是 3 票全過**，沒有漏投、沒有中斷、沒有任何一條被降低嚴重度。
- **首腦沒有只採信工具**：兩條都親自打開檔案逐行核對過，F1 的行號還因此更正了一處。
- **F2 的關鍵前提另有資料庫實查佐證**（8 個客戶的管理員實際持有該權限、全域設定確實只有一份、新客戶開通時自動發放），可信度因此從中等調升到高。
- **結果沒有過期**：掃描後這 25 個檔案至今一個字都沒改（首腦比對掃描版本到目前的程式碼差異為空）。

### 「是不是只有這兩條」→ ❌ 不可信，三個理由

**理由一：這是快篩，不是全面檢查。** 用的是最低強度模式——一位研究員把 25 個檔案讀完就提報，不做元件盤點、不做威脅建模、不跑密鑰專項掃描。所有結論都出自「讀程式碼」，**沒有實際打過任何 API、沒有跑過任何攻擊驗證**。

**理由二（最重要）：卡片列了八個要追查的點，工具只碰了一部分。**

| 卡片上的追查點 | 結果 |
|---|---|
| ⑤ 三支端點的權限守門不一致 | ✅ **實質回答了**——就是 F1 |
| ② 權限分流表有沒有映得太寬鬆 | 🔶 **只碰到一半**——F2 正是「權限層級標錯」的一個實例，但**那張對照表本身沒有被逐項檢視** |
| ③ 六處「關掉客戶隔離」的查詢有沒有收窄 | 🔶 **只碰到一半**——F2 用到其中一處當作機制，但**六處沒有逐支稽核** |
| ⑥ 遮罩名單在主專案是不是又建了第二份 | 🔶 **只碰到一半**——F1 碰到名單漏了檔案儲存設定，但「主專案有沒有第二份名單」**沒查** |
| ① 四個地方繞過守門版服務 | ⬜ **完全沒碰**（`auth_containers.py:180`／`notification_containers.py:20`／`user_change_password_containers.py:46`／`login_containers.py:14-15`） |
| ④ 那支「有就更新、沒有就新增」的寫入，兩個人同時打會怎樣 | ⬜ **完全沒碰** |
| ⑦ 出廠快照的隱藏是不是滴水不漏 | ⬜ **完全沒碰** |
| ⑧ 新客戶繼承設定會不會繼承到別家的 | ⬜ **完全沒碰** |

🔴 **⬜ 的意思是「沒有被提出」，不是「查過沒問題」。** 尤其第 ① 點**是卡片自己標成「本棒最重要的一條」的**，而它**完全沒有被回答**——下一任接手的人不要以為那四條路徑是乾淨的。

**理由三：範圍只涵蓋一半。** 這一棒只掃「主專案接線」這 25 個檔，選單字典那一棒（P2，33 個檔）還沒跑。**現在還不能說「系統設定核心已經掃過了」。**

---

## 執行概況

| 項目 | 數字 |
|---|---|
| 檢查範圍 | 25 個檔案（與卡片逐檔對上） |
| 檢查強度 | 最低（`low`），聚焦「攻擊面」 |
| 派出／回報的研究員 | 1 / 1 |
| 候選問題 → 去除重複 | 2 → 2 |
| 投票數 | **6**（2 條發現 × 3 位檢查員），**兩條都 3 票全過** |
| 沒投到票的 / 投票中斷的 / 被降低嚴重度的 | 0 / 0 / 0 |
| 驗證章狀態 | `verified`（無拒收理由） |
| 掃描當下的程式碼版本 | commit `2b11bd54`，branch `main`，工作區乾淨 |
| 耗時 | **13 分 10 秒**（790 秒） |

### 為什麼只跑 13 分鐘？（不是草草了事）

上一棒（P1）跑了 2 小時 39 分，這一棒 13 分鐘，差了一個數量級，看起來很可疑。原因是：

**投票的成本 ＝ 候選問題數 × 3 位檢查員。** P1 有 4 條候選要投，這一棒只有 2 條，時間自然短。**流程一步都沒有跳過**——驗證章是完整的 `verified`，6 票全投、沒有中斷、沒有未審的候選。

短的原因是「找到的東西少」，不是「檢查得少」。至於「找到的東西少」是不是等於「問題少」，看上面「是不是只有這兩條」那一段——答案是不能這樣推論。

---

> 📄 **本報告由首腦依工具原始產物補寫**（該棒 runner 未交付報告）；掃描本身完整有效，缺的只是「寫成給人看的文件」這一步。
