---
title: U12 檢查結果：零散殘段＋系統設定守門補掃＋人員對帳跨公司比對
---

# U12 檢查結果：零散殘段＋系統設定守門補掃＋人員對帳跨公司比對

> 檢查日期 2026-09-25｜對應卡片 CM-2164（母卡 CM-2152）｜範圍 16 個檔案、1,921 行｜工具 run ID `wf_a2018933-ffc`｜掃描版本 `e1c92303`（`feature/review`）｜驗證章 `verified`（3 候選、9 票全投完、零漏投）

## 🔴 一句話結論

**卡片點名最要緊的「系統設定守門」確實是這一棒的重點：工具兩條 3:0 都落在這支，而且都是總表已知的洞（第 74、72 項），掃描當時主線一條都還沒修（後已修，1.21.0 出貨）。本棒淨新增的是修正線那道「總部層級」守門本身的一個縫：它擋得住子公司，擋不住別家客戶的總部。**

- **工具兩條，都是舊帳（驗證章 `verified`，3 候選 9 票全投完）**：F1（高，3:0）任何客戶的管理員都能改掉全站唯一那一列寄信／LDAP 設定＝**總表第 74 項**；F2（中，3:0）任何登入者讀得到物件儲存的帳號密碼明文＝**總表第 72 項**。工具另報的第三條「即時通知連線不驗身分」被面板 **0:3 否決**。
- **系統設定守門（卡片第一重點）：AI 金鑰和 Google 雲端硬碟設定兩組的「能力點＋平台管理員」兩道都在；其他設定群組只有一道能力點，而這是刻意的，不是漏。** 真正的缺口是總表第 74 項那三組（登入規則、LDAP、寄信）：**主線（`feature/review`）上仍然只有能力點一道，子公司管理員還是寫得進全站共用那一列**。修正線（`fix/security-b1`）已經補了「總部層級」第二道（CM-2054），但**我實測發現那道守門擋得住「同一家客戶的子公司」，擋不住「另一家客戶的總部」**——而寄信和 LDAP 的共用列是**全站只有一列**，不是每家客戶一列。
- **人員對帳跨公司比對（卡片第二重點）：人員比對程式收到了「是哪家公司」這個參數，但完全沒拿來用**；組織比對有用。今天會不會真的配到別家的人，取決於資料庫隔離：一般客戶的使用者看不到別家的人（我在開發環境實測，客戶 102 的身分只看得到 38 人、看不到客戶 131 的 2 人），**但平台管理員身分的隔離是全開的，比對會橫跨所有客戶**。配對結果只寫進「對到哪個使用者編號」的附註欄，不會把人加進專案或給權限；影響是 SSP 文件上多一個別家公司的人名、email 和編號。
- **兩支解析工作單程式：新增那一條的資料庫保護是「任何人都能新增」（`WITH CHECK (true)`），但寫進去的公司編號一律來自登入身分，不從網址或內容拿**，目前沒有可以利用的路。讀／改／刪的保護是正確版本（5 月已修好），跟總表第 178 項那兩張壞掉的表不一樣。
- **即時通知連線（`notification_socketio_handler.py`）真的完全不驗身分，但目前沒有東西經過它**：不用登入就能連、進任何房間、對房間廣播任意內容，我實測重現。面板否決的理由我開檔核對過屬實——**前端唯一會連它的元件 `ProcessDiscussBox.vue` 沒有任何地方引用**，伺服器也從不往這個頻道送資料。所以今天是一扇沒有門的空房間：**評低**，但哪天有人把討論串元件接回畫面，就會變成不用帳號就能偷聽別家討論串。
- **防竄改接線 `common/integrity/adapters.py`：不寫 `api_logs`，只讀。** 卡片問的「雜湊鏈寫入 `api_logs` 的租戶歸屬」前提不成立。有一個小問題：竄改事件旁證裡的「來源 IP」直接讀 `X-Forwarded-For` 標頭，可以偽造；修正線已改掉。
- **新租戶設定繼承（`tenant_storage_config_seeder.py`）**：寫入有限定租戶；把原廠 AI 金鑰整格抄給新客戶這件事＝總表第 161 項，修正線 CM-2192 已修，主線還沒合回。

## 這一棒在檢查什麼

三塊零散但決策者裁定一起做的程式：

1. **原本盤點的零散殘段（7 支）**：防竄改功能的主專案接線、兩張「匯入解析工作單」表的讀寫程式、即時通知的 WebSocket（瀏覽器與伺服器之間長時間開著的雙向連線）處理器、SSP 人員欄位的格式定義。
2. **系統設定守門服務（1 支 665 行＋1 支新租戶設定繼承）**：上次掃描之後又加了 391 行，新增的正是「哪些設定只有平台管理員能動」的守門本身。
3. **人員對帳（6 支）**：使用者上傳 Word／Excel 格式的 SSP（系統安全計畫）時，系統會把文件裡寫的人名、email、單位名稱，自動對到系統裡的使用者與組織，並在 SSP 上註記「這個人＝系統裡的哪個帳號」。問題是：比對的時候會不會拿到別家公司的人。

| 檔案 | 行數 | 角色 |
|------|-----:|------|
| `app/system_config/service/guarded_system_config_service.py` | 665 | 系統設定讀寫的總守門：遮罩機密、共用設定落總部那列、受限群組加驗平台管理員 |
| `common/integrity/adapters.py` | 357 | 防竄改套件的主專案接線：驗章、機器指紋、竄改時蒐集旁證 |
| `app/system_config/service/tenant_storage_config_seeder.py` | 129 | 建新客戶時，把原廠的儲存設定與 AI 設定抄一份給他 |
| `domain/oscal/service/reconciliation/base.py` | 107 | 人員對帳共用骨架：人工指定 → 完全相同 → 正規化後相同 → 模糊比對 四段 |
| `domain/oscal/service/reconciliation/person_reconciler.py` | 101 | 人員比對：用 email、帳號名稱找系統使用者 |
| `domain/oscal/service/reconciliation/organization_reconciler.py` | 101 | 組織比對：用單位名稱找組織 |
| `domain/oscal/service/ssp_docx_parse_job_domain_service.py` | 71 | Word 匯入解析工作單的建立／狀態轉換 |
| `domain/oscal/service/ssp_excel_parse_job_domain_service.py` | 71 | Excel 匯入解析工作單的建立／狀態轉換 |
| `infra/oscal/repository/ssp_excel_parse_job_repo_impl.py` | 56 | Excel 解析工作單表的讀寫 |
| `infra/oscal/repository/ssp_docx_parse_job_repo_impl.py` | 54 | Word 解析工作單表的讀寫 |
| `app/notification/handler/notification_socketio_handler.py` | 53 | **即時通知 WebSocket 處理器** |
| `domain/oscal/service/reconciliation/_normalizers.py` | 51 | 比對前的字串整理（去空白、去 `+別名`、去「股份有限公司」） |
| `api/oscal/serializers/ssp/ssp_party.py` | 47 | SSP 人員欄位的輸入／輸出格式 |
| `domain/oscal/service/party_reconciliation_service.py` | 30 | 人員對帳入口：分派給人員／組織比對 |
| `domain/oscal/service/reconciliation/__init__.py` | 18 | 套件匯出 |
| `domain/oscal/service/reconciliation/match_method.py` | 10 | 比對方式的列舉值 |

## 掃到什麼（總覽）

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度＋為什麼 | 來源 |
|---|---|---|---|---|---|---|
| F1 | 任何客戶的管理員都能改掉全站唯一那一列寄信伺服器／LDAP 設定，進而攔截任何人（含平台管理員）的重設密碼信 | 見總表第 74 項 | 任一客戶的系統管理員（新客戶開通時自動拿到能力點） | `guarded_system_config_service.py:458`（寫入全站共用列之前要求平台管理員） | 🔴 高 | **工具 F1，面板 3:0；＝總表第 74 項，不另計** |
| F2 | 任何登入者都讀得到物件儲存的帳號密碼明文 | 見總表第 72 項 | 任一有效登入 | `guarded_system_config_service.py:554`（儲存設定比照 AI／雲端硬碟遮罩）；`api/system_config/routes/system_config_route.py:131`（讀取補能力點） | 🟠 中 | **工具 F2，面板 3:0；＝總表第 72 項，不另計** |
| U12-1 | 即時通知連線不驗身分、加入任何房間不檢查、`reload` 事件把呼叫端內容原樣廣播 | **今天沒有實害**：伺服器從不往這個頻道送資料、前端唯一的使用元件沒被引用。哪天討論串元件接回畫面、或伺服器那行 namespace 對齊，就變成不用帳號偷聽別家流程討論串（含留言者帳號與留言全文） | 不需要任何帳號；偷聽要知道房間名稱（`process-discussion:<流程編號>`） | `app/notification/handler/notification_socketio_handler.py:19`（改繼承 `AuthenticatedNamespace`）；`:34` `on_join` 核對流程歸屬；`:43` `on_reload` 整支刪掉 | 🟢 低：門完全沒鎖，但房間裡沒東西 | 工具 C3，**面板 0:3 否決**（否決理由 runner 開檔核對屬實）；runner 實測重現「不驗身分」本身 |
| U12-3 | 修正線的「總部層級」守門：同一家客戶的子公司擋得住，**另一家客戶的總部擋不住**，而寄信／LDAP 的共用列全站只有一列 | 客戶 B 的總部管理員仍然能改掉全站唯一那一列寄信伺服器／LDAP 設定，影響到客戶 A 和平台自己 | 另一家客戶的總部管理員帳號（持有 `smtp-config.update` 或 `ldap-config.update`，新客戶開通時自動拿到） | 修正線 `common/authz/tenant_hq.py`（判準是「站在第二層」，沒區分「是哪一家的第二層」）；產品面要先決定：寄信／LDAP 究竟是「全站一份」還是「每家客戶一份」 | 🔴 高（若產品定義是全站一份）：跟第 74 項原本的危害完全相同，只是攻擊者從「任何客戶的子公司管理員」縮小到「任何客戶的總部管理員」 | **runner 對修正線實測**（未經三人面板）；見下方「疑點 ②」 |
| U12-4 | 人員比對收到公司編號但沒拿來過濾；平台管理員身分下會橫跨所有客戶比對 | 平台管理員替客戶 A 匯入 SSP 時，文件上的 email 若剛好對到客戶 B 的某人，SSP 上會註記成客戶 B 那個人的使用者編號；匯入當下預覽畫面顯示對方名字與帳號，編號隨 SSP 匯出帶出去（客戶 A 自己的使用者之後打開，名字會被資料庫隔離擋成空白） | 平台管理員身分操作匯入；文件裡的 email 在別家公司有同名帳號（開發環境已有 1 組實例） | `domain/oscal/service/reconciliation/person_reconciler.py:26`、`:41`、`:58`、`:76`（四處查詢都要比照組織比對加 `tenant_id`） | 🟢 低：一般客戶身分被資料庫隔離擋住；配對結果只是文件上的附註，不給權限、不把人加進專案 | runner 開檔＋實測（未經三人面板） |
| U12-5 | 解析工作單兩張表的「新增」資料庫保護是 `WITH CHECK (true)` | 程式若哪天從請求內容拿公司編號，資料庫不會擋 | 目前沒有路：公司編號一律取自登入身分 | 出貨基線 `scripts/init/02-schema.sql:24970`、`:25004` | 🟢 低（第二道防線有缺、第一道完整） | runner 開檔＋DEV 唯讀查 `pg_policies`（未經三人面板） |
| U12-6 | 防竄改旁證的「來源 IP」直接讀 `X-Forwarded-For` 標頭 | 竄改事件的旁證紀錄可被偽造 IP，事後追查誤導 | 在竄改被偵測的那個請求裡帶假標頭 | `common/integrity/adapters.py:269-273`（修正線已改成 `request.remote_addr`） | 🟢 低：只影響旁證，旁證本身文件就寫明「不是身分溯源」 | runner 開檔（未經三人面板）；修正線已修 |

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - U12-1（即時通知頻道不驗身分）＝總表第 213 項，✅ 已修（拔除，CM-2373，1.21.1；頻道、留言 API 與前端元件一併拆除）。
> - U12-3（總部層級守門）＝總表第 212 項，✅ 已修（CM-2202，commit `6cfcb116e`，1.21.0 出貨）。
> - U12-4（人員對帳不分公司）＝總表第 214 項，✅ 已修（CM-2211，commit `fe928859f`，1.21.0 出貨）。
> - U12-5（解析工作單表新增規則）＝總表第 215 項，✅ 已修（CM-2215，commit `822216b6c`，1.21.0 出貨）。
> - U12-6（旁證來源 IP）＝總表第 216 項，✅ 已修（FR-114 CM-2171，commit `908f9dd0e`，1.21.0 出貨）。
> - 工具 F1／F2 舊帳：總表第 74／72 項，均 ✅ 已修。

## 工具報的逐條

工具只讀程式碼、沒有執行任何攻擊；三條候選各由三位檢查員從「打不打得到」「後果多大」「有沒有別的防線」三個角度投票。

### F1 🔴 任何客戶的管理員都能改掉全站唯一那一列寄信／LDAP 設定（高，3:0）＝總表第 74 項

- **在哪**：`app/system_config/service/guarded_system_config_service.py:458`（`_write_shared_root_config`）。
- **怎麼打**：任一客戶的系統管理員送 `PUT /api/1.0/system/config/SMTP/*`，把主機改成自己的機器、`changePwd: false`（系統會保留原本的寄信密碼）。然後對任何人（包括平台管理員）按「忘記密碼」，重設信就經過攻擊者的郵件伺服器，攻擊者拿到重設連結就能改掉對方密碼。系統登入攻擊者的郵件伺服器時，還會把公司真正的寄信帳密送過去。LDAP 同理：指到攻擊者的帳號目錄，就能控制所有用 LDAP 登入的人。
- **為什麼擋不住**：唯一的檢查是能力點 `smtp-config.update`／`ldap-config.update`，而這兩個是客戶層級、每家客戶的系統管理員開通時自動拿到；`_platform_admin_only`（`:252-257`）只列 AI 與雲端硬碟兩組。寫入走繞過資料庫隔離的 `write_root_config_value`。
- **面板**：打不打得到 TRUE（高）、後果 TRUE（高）、防線 TRUE（中）。最終高。
- **首腦既有結論**：＝總表第 74 項（FR-096 H1 F2 已登記，並在 FR-115 W7 重現過一次）。**本棒工具再撿到一次、補了一個具體後果：重設密碼信被攔截 → 可接管平台管理員帳號**。主線現況未變（見疑點 ②）。
- **我自己核對**：開檔確認 `:458`、`:651-657`、`:607-616` 三處寫入都沒有平台管理員檢查；DEV 唯讀查三個能力點 `is_platform=f`（2026-09-25 15:30）；寄信讀取端 `app/notification/service/notification_service.py:58` 確實讀 `read_root_config_value("SMTP","*")`。**屬實。**

### F2 🟠 任何登入者讀得到物件儲存帳密明文（中，3:0）＝總表第 72 項

- **在哪**：`app/system_config/service/guarded_system_config_service.py:554`（`get_system_config_by_key`）；入口 `api/system_config/routes/system_config_route.py:131` 只有 `@jwt_required`。
- **怎麼打**：任一登入者（例如沒有 `storage-config.*` 能力點的稽核員）打 `GET /api/1.0/system/config/STORAGE_CONFIG/CONFIG`，回應裡 `access_key`／`secret_key` 是明文。
- **為什麼擋不住**：`_mask_secrets`（`:234-245`）只遮 AI 與雲端硬碟兩組；套件的遮罩名單沒有 `STORAGE_CONFIG`。
- **面板**：三個角度都 TRUE（中）。前提是攻擊者連得到物件儲存伺服器——落地版 SeaweedFS 只在內網。
- **首腦既有結論**：＝總表第 72 項（與第 39 項同一件）。修正線 CM-2063 已補儲存設定遮罩（`_mask_storage`），但**修正線的 `system_config_route.py:131` 讀取入口仍然沒補能力點**（我開 `fix/security-b1` 那一行確認）——遮罩補了，門還是開的；拿不到密鑰了，但端點、帳號、儲存桶名稱仍然看得到。

### C3（被否決，0:3）即時通知連線不驗身分

- 工具報的內容與我的 U12-1 相同：連線不驗、房間任意進、`reload` 原樣轉發。
- **三位檢查員都同意「門沒鎖」是真的，但都判定「沒有實害」**，理由三點：①伺服器唯一的廣播（`flow_engine_route.py:121`）送往 `/socket/process-discussion`，不是這個頻道，所以進了房間也聽不到任何東西；②**前端唯一連這個頻道的元件 `ProcessDiscussBox.vue` 沒有任何地方引用，是死碼**；③就算接回去，收到 `reload` 也只是用受害者自己的登入身分重抓留言，不顯示廣播內容。
- **我核對**：grep FE repo，`ProcessDiscussBox` 除了元件本身零引用；`/socket/notification` 在 jedi 套件裡也沒有任何人往裡送。**否決理由屬實**，我原本的「中」降為「低」（見 U12-1 與疑點 ⑥）。

### 密鑰掃描

零發現。

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

### ① `guarded_system_config_service.py`：守門的涵蓋名單完不完整？其他設定群組有沒有兩道？

**先講結論：AI 金鑰（`AI_PROVIDER_CONFIG`）和 Google 雲端硬碟應用程式設定（`GOOGLE_DRIVE_APP_CONFIG`）兩組有「能力點＋平台管理員」兩道；其他群組只有能力點一道，這是設計，不是漏。**

逐組對照（主線 `feature/review`）：

| 設定群組 | 畫面 | 第一道：能力點 | 第二道 | 實體放在哪 | 只有一道要不要緊 |
|---|---|---|---|---|---|
| `AI_PROVIDER_CONFIG` | AI 服務設定 | 泛用端點走 `system_config.*`（誰都沒有，只有超級管理員）；選單綁 `storage-config.*` | ✅ 平台管理員（`:252-277`） | 每家客戶一列 | — |
| `GOOGLE_DRIVE_APP_CONFIG` | 雲端硬碟應用程式設定 | 同上 | ✅ 平台管理員 | 平台那一列 | — |
| `STORAGE_CONFIG` | 儲存設定 | `storage-config.*`（非平台層） | 只有一道 | **每家客戶一列**，資料庫隔離擋別家 | 不要緊：改的是自己那列 |
| `NOTIFY_CONFIG` | 通知設定 | `notify_config.*`（非平台層） | 只有一道 | **每家客戶一列**（讀取端 `tenant_notify_config_reader.py:37` 按租戶讀） | 不要緊 |
| `ISSUE_INTEGRATE_CONFIG` | 議題整合（GitLab／GitHub） | `issue-integrate-config.*`（**平台層**，DEV 實查 `is_platform=t`） | 能力點本身就只發給平台 | 平台那一列 | 不要緊：客戶拿不到能力點 |
| `SMTP` | 寄信伺服器 | `smtp-config.*`（**非平台層**，DEV 實查） | ❌ **主線沒有** | **全站只有平台那一列**（`shared_config_app_service.py:38-41`） | 🔴 **要緊＝總表第 74 項** |
| `THIRD_PARTY_LOGIN/LDAP` | LDAP 帳號目錄 | `ldap-config.*`（非平台層） | ❌ 主線沒有 | 全站只有平台那一列 | 🔴 同上 |
| `RUNTIME_CONFIG` | 登入安全政策 | `security-policy.*`（非平台層） | ❌ 主線沒有（專屬端點 `security_policy_app_service.py:162` 也沒有） | 全站只有平台那一列 | 🔴 同上 |
| `PASSWORD_POLICY`、`WEB_IDEL_CONFIG` 等不在對照表的 | — | 退回 `system_config.*`（**平台層**，DEV 實查 `is_platform=t`） | 能力點本身就只發給平台 | 平台那一列 | 不要緊 |

判斷方法：「只有一道要不要緊」取決於**這組設定的實體是每家一列、還是全站一列**。每家一列的，能力點加資料庫隔離就夠；全站一列的（SMTP、LDAP、登入規則），能力點發給了每家客戶的管理員，就一定需要第二道。守門服務裡的 `_platform_admin_only`（`:252-257`）名單只列 AI 與雲端硬碟兩組，**這份名單的判準是「第一版不開放客戶自填」，不是「全站共用一列」**——所以 SMTP／LDAP 不在裡面是對的，它們的第二道本來就應該是另一條規則（總部層級），而那條規則主線還沒有。

另外看到兩件相關但不在本棒範圍的事：

- **讀取端的三個入口只檢查登入**（`api/system_config/routes/system_config_route.py:131` 與套件的兩支）＝總表第 72 項／第 39 項，**修正線上 `:131` 仍然沒補**（我開修正線 `fix/security-b1` 那一行確認過）。本棒守門服務的讀取路徑對 AI／雲端硬碟兩組有補平台管理員，所以這兩組的讀取是安全的；其他組靠第 72 項那張工單。
- **AI 儀表板的「查系統設定」**（`di_containers/dashboard_apis/system_config.py`）沒有申報 `required_capabilities`，AI 儀表板套件的新版規則是「沒申報就拒絕」，所以這支目前是**拒絕**狀態，不是公開。

### ② 總表第 74 項那三組（登入規則、LDAP、寄信）現在是不是還讓子公司管理員寫進全站共用那一列？

**主線：是，還是寫得進去。修正線：子公司擋住了，但另一家客戶的總部還寫得進去。**

**主線（`feature/review`，本棒掃描版本）**：

- 寫入入口有四條：宿主 `PUT /system/config/<group>/<key>`（`system_config_route.py:142-150`）、套件 by-uid `PUT`／`DELETE`、套件 `POST`、登入政策專屬端點。
- 四條都只做能力點檢查（`core/plugins/system_core.py:98-115` 的 `assert_config_capability`）。守門服務裡的 `_assert_restricted_group_access` 對這三組直接 `return`（因為不在 `_platform_admin_only` 名單）。
- 之後 `update_config_by_group_key`（`:651-657`）與 `update_system_config`（`:607-616`）看到是共用設定，就走 `_write_shared_root_config` 寫進平台那一列，**繞過資料庫隔離**（`infra/system_config/system_config_root_reader.py` 的 `write_root_config_value`）。
- DEV 唯讀實查（2026-09-25 15:30）：`smtp-config.update`、`ldap-config.update`、`security-policy.update` 三個能力點 `is_platform` 都是 `f`。
- 結論：**＝總表第 74 項，主線現況未變**。

**修正線（`fix/security-b1`，CM-2054 `e90d8e3cb`）**：

- 新增「軸⑦ 總部層級」`common/authz/tenant_hq.py`，名單 `common/constant/company_wide_config.py` 列了 `SMTP`、`THIRD_PARTY_LOGIN`、`RUNTIME_CONFIG` 三組。
- 守門服務的六個寫入方法、`core/plugins/system_core.py` 的能力點分流、登入政策專屬端點，**全部都補了**，連 by-uid 寫共用列那條提前 return 的分支（`update_system_config` 裡）也補了。**覆蓋面我逐條對過，沒有漏的入口。**
- **但判準有一個洞**：`viewer_is_tenant_hq()` 的判準是「可見租戶路徑最短那條剛好兩層」。我寫了一段小程式實測：

  | 身分 | 路徑 | 能不能寫全站共用那一列 |
  |---|---|---|
  | 客戶 A 總部 | `/1/102/` | 能 |
  | 客戶 A 子公司 | `/1/102/152/` | **擋住** ✅ |
  | **另一家客戶的總部** | `/1/131/` | **能** ⚠️ |

  寄信與 LDAP 的共用列是**全站只有一列**（`SHARED_ROOT_CONFIGS` 寫入一律落平台那列，讀取端 `read_root_config_value("SMTP","*")` 也只讀那列）。所以客戶 131 的總部管理員改寄信伺服器，改到的是客戶 102 和平台自己在用的那一台。**第 74 項原本的危害在修正線上只從「任何客戶的子公司管理員」縮小成「任何客戶的總部管理員」，沒有消失。**
- 這個洞的根源是**產品定義還沒說清楚**：第 74 項討論時補的「方向④」寫的是「只給客戶自己的第一層租戶改」，這句話在「每家客戶有自己一份」的前提下才成立；但寄信／LDAP 目前的資料模型是全站一份。修正線照方向④寫，卻沒有把資料模型改成每家一份。
- **修法要先做產品決策（二選一）**：①寄信／LDAP 真的是全站一份 → 這三組的寫入改成**只有平台管理員**（`require_platform_admin`，現成）；②每家客戶該有自己一份 → 要把 `SHARED_ROOT_CONFIGS` 的落點從「平台那列」改成「該客戶總部那列」，讀取端（寄信、LDAP 登入）也要跟著改成按客戶讀，這是一件資料模型的改動，不是守門。登入規則（`RUNTIME_CONFIG`）同理。

### ③ `tenant_storage_config_seeder.py`：AI 設定寫入時有沒有限定租戶？

**有。** 寫入走 `infra/system_config/tenant_config_seed_writer.py:70-84` 的 `INSERT ... ON CONFLICT DO NOTHING`，`tenant_id` 是呼叫端傳進來的新客戶編號，而資料庫的新增規則是 `app_tenant_allowed_for_session(tenant_id)`（沒有超級管理員例外），建立者的身分必須看得到那家新客戶才寫得進去。**只會新增、永遠不會蓋掉既有列**。

真正的問題是「抄了什麼」：主線把原廠 `AI_PROVIDER_CONFIG` 整格（含金鑰）照抄給每家新客戶（`:52-55`）＝**總表第 161 項**。修正線 CM-2192（`21874f869`）已加 `_strip_ai_secrets` 只抄非機密欄位，**主線尚未合回**。

### ④ `common/integrity/adapters.py`：雜湊鏈寫入 `api_logs`（沒有客戶隔離）時的租戶歸屬？

**前提不成立：這支不寫 `api_logs`。** 全檔唯一碰資料庫的地方是 `_active_sessions_snapshot`（`:284-327`），對 `public.api_logs` 做**唯讀** `SELECT`，目的是竄改事件成立當下，記一份「最近 30 分鐘有哪些帳號在線」當旁證。它不分客戶是對的——竄改是整台主機的事件，不屬於任何一家客戶；而這份旁證只寫進防竄改套件自己的事件表，不回給任何 API。

沒有雜湊鏈寫入；驗章用的是套件內建公鑰（`LicenseEngineVerifier`），環境變數只能改「標記檔放哪」「要不要在源碼模式強制」這類部署參數，註解明寫「可設定＝可關閉」的參數刻意不接環境變數。

順帶看到一個小問題（U12-6）：`_current_request_context`（`:269-273`）的來源 IP 優先讀 `X-Forwarded-For` 標頭，呼叫端可以自己帶假的。影響只是竄改旁證被誤導，修正線已改成讀 `request.remote_addr`（由 ProxyFix 設定）。

### ⑤ 兩支解析工作單 repo：已知資料庫保護規則寫壞，這兩支就是讀寫它們的程式

**這兩張（`ssp_docx_parse_jobs`／`ssp_excel_parse_jobs`）的讀／改／刪規則是對的；寫壞的是另外兩張。**

- DEV 唯讀查 `pg_policies`：兩張表的 `SELECT`／`UPDATE`／`DELETE` 都是 `is_super_admin='t' OR app_tenant_allowed_for_session(tenant_id)`——這是 5 月 `2026-05-01-fix-ssp-docx-parse-jobs-rls.sql` 修好的正確寫法。
- 總表第 178 項說的壞規則是 **`ap_docx_parse_jobs`／`ar_xlsx_parse_jobs`**（稽核計畫與稽核結果），不是這兩張。盤點檔 6 節把 SSP 這兩張也標「RLS 規則寫壞」是誤植。
- **仍有一個小縫（U12-5）**：`INSERT` 規則是 `WITH CHECK (true)`——任何身分都能新增任何公司編號的工作單。目前打不到，因為建立工作單時公司編號一律取自登入身分（`ssp_docx_import_app_service.py:170` `tenant_id=user_context.tenant_id`），請求內容裡沒有這個欄位。
- 程式本身：`update`／`deactivate`（`ssp_docx_parse_job_repo_impl.py:31-54`）只用編號查、不帶公司條件，靠資料庫隔離擋；呼叫端（`ssp_docx_import_app_service.py:256-300`）讀、捨棄、確認三步都先核 `job.tenant_id != user_context.tenant_id`，外加專案參與者／管理員檢查。`update` 會跳過 `tenant_id`、`created_user` 等保護欄位（`:12`）。Excel 那支同構（多一個 `metadata` 欄位名對映），`:253`／`:294`／`:320` 同樣三步都核。**不成立。**

### ⑥ `notification_socketio_handler.py`：連線時怎麼認身分？能不能訂閱別人的通知頻道？

**不認身分；能訂閱任何頻道；還能對任何頻道廣播——程式碼層面成立，我親手重現。但今天這個頻道上沒有任何東西，所以沒有實害（工具 C3 被面板 0:3 否決，否決理由我核對屬實）。評低（U12-1）。**

**程式碼**：

- `:19` `class NotificationSocketioHandler(Namespace)`——繼承的是 flask-socketio 原生的 `Namespace`，不是 `jedi_iam.middleware.socketio_auth.AuthenticatedNamespace`。同伺服器上另一支 `FillSurveySocketioHandler` 繼承的是後者，它會在連線時驗登入憑證（`authenticate_socket_connection`，驗不過拒連）、每個事件前重新確認身分。
- `:23-31` `on_connect`：印 log、回一句「Connected」。**沒驗任何東西。**
- `:34-41` `on_join`：`join_room(data.get('room'))`，房間名稱完全由呼叫端決定。
- `:43-52` `on_reload`：`emit('reload', data, room=data.get('room'))`，把呼叫端送的整包內容原樣廣播給該房間所有人。
- 註冊在 `config/socketio_namespaces.py:41-45`，路徑 `/socket/notification`；伺服器 `core/app_factory.py:180-186` 設定 `cors_allowed_origins="*"`（任何網站都能連）。落地版 `docker/production/docker-compose.yml:314` 由 nginx 把 `/socket.io` 代到 socketio 服務，**與前端同網址、對外可達**。

**誰在用——沒有人**：前端唯一連這個頻道的是 `ProcessDiscussBox.vue:37`（流程討論串，加入房間 `process-discussion:<流程編號>`、收到 `reload` 就重抓留言），但**這個元件整個 FE repo 沒有任何地方引用**（面板指出、我 grep 確認）。伺服器端在有人留言時由 `api/flow_engine/routes/flow_engine_route.py:115-121` 廣播 `{room, user_name, message}`（**留言者帳號與留言全文**），但送的是 `/socket/process-discussion`，這個頻道沒註冊，也不是 `/socket/notification`。jedi 套件也沒有人往 `/socket/notification` 送東西。

**所以今天的狀態**：門完全沒鎖，但房間是空的——連得進來的只有其他匿名連線。**會變成真問題的時機**：有人把討論串元件接回畫面、並把伺服器那行 namespace 對齊成 `/socket/notification`（兩件都像是「修個即時更新」會順手做的事）。那一刻起，不用帳號就能偷聽任何知道編號的流程討論串。**建議趁現在沒人用，把門先裝上。**

**實測**（scratchpad `sock_test.py`，用 flask-socketio 的 test client 直接載入這支處理器）：

```
attacker connected w/o token: True
victim received: [('reload', [{'room': 'process-discussion:SOME-OTHER-TENANT-WORKFLOW-ID', 'user_name': 'boss', 'message': 'FAKE'}])]
```

攻擊者不帶任何登入憑證就連上、加入房間、送出 `reload`，同房間的其他連線收到一則冒名「boss」的內容。這證明「不驗身分、房間任意進、內容原樣轉發」三件都成立；但如上所述，今天房間裡不會有真的使用者。

**打折說明**：用 test client 在單一行程內模擬，不是對真實落地版的 8002 連線；沒有接 Redis message queue。處理器程式碼就是那 53 行，沒有其他地方可以補身分檢查，所以結論不受影響。

**修法**：①`:19` 改繼承 `AuthenticatedNamespace`（一行，連線與事件身分就都有了）；②`on_join` 加入前核對 `process-discussion:<id>` 這個流程是不是呼叫者看得到的（比照問卷那支的 `_assert_participant`）；③`on_reload` 整支刪掉，前端沒有任何地方送 `reload`（我 grep 過 FE repo，唯一的 `emit('reload')` 是 Vue 元件事件，不是 socket）；④順便把 `flow_engine_route.py:121` 的 namespace 對齊。

### ⑦ 人員對帳：跨公司比對時拿別家的人名 email 來配對，配對結果會不會把別家的人寫進自己的 SSP？

**會有條件地發生：一般客戶身分被資料庫隔離擋住；平台管理員身分下會。寫進 SSP 的是「附註」，不是權限。**

**程式碼**：

- 入口 `party_reconciliation_service.py:21-30` 把 `tenant_id` 傳給人員與組織兩個比對器。
- **組織比對**（`organization_reconciler.py:26-27`、`:44`、`:62`、`:83`）四個查詢都有 `if tenant_id is not None: q.tenant_id = tenant_id`。✅
- **人員比對**（`person_reconciler.py`）四個查詢——人工指定（`:26` 用帳號名稱）、完全相同（`:41` 用 email）、正規化（`:58`）、模糊（`:76` 抓**全部**使用者當候選）——**全部沒帶 `tenant_id`**。參數收到了、沒用。

**實測**（scratchpad `recon_test.py`，假的使用者服務記下收到的查詢條件）：

```
person query filters: {'email': 'alice@vendor.com'} -> matched_user_id 777 exact
org query tenant filter: ('org', 5)
label query filters: {'login_name': 'alice_b'} -> matched 777 user_selected
```

替公司 5 做比對，人員查詢沒帶公司條件，直接配到公司 7 的使用者 777；組織查詢有帶公司 5。

**資料庫隔離擋不擋**（DEV 唯讀交易，`cm_app` 身分，2026-09-25 15:4x）：

| 模擬身分 | 看得到的使用者 | 其中客戶 131 的人 |
|---|---:|---:|
| 客戶 102 的一般使用者（`allowed_tenant_paths=/1/102/`） | 38 | **0** |
| 平台管理員（`is_super_admin=t`） | 42 | **2** |

`users` 表的讀取規則是 `is_super_admin OR app_tenant_allowed_for_session(tenant_id) OR 是自己`。一般客戶看不到別家；**平台管理員（路徑 `/1/`）的隔離是全開的**（`jedi_common/session/database/db.py:190-195` 路徑是根就設 `is_super_admin=t`）。DEV 另有 1 組 email 同時存在於兩家客戶。

**所以**：平台管理員替客戶 102 匯入一份 SSP，文件上寫的 email 若在客戶 131 也有帳號，完全相同比對可能先拿到 131 那個人（資料庫回傳順序決定）。模糊比對更寬：同網域＋名字相同就配。

**配到了會怎樣**：

- 解析時寫進工作單的 `parsed_result`（`ssp_docx_import_app_service.py:629`、`ssp_excel_import_app_service.py:885-889`），預覽畫面會顯示那個人的暱稱與帳號（`party_match_enrich.py`）。
- 確認匯入後寫進 SSP 人員的 OSCAL 附註欄 `matched-user-id`（`import_adapter/_common.py:121-123`）。之後 SSP 人員清單（`ssp_party_app_service.py:70-74`）與 Excel 範本下載（`ssp_import_template_app_service.py:1111`）會用這個編號反查出名字與帳號。
- **不會**把人加進專案參與者、**不會**給任何權限，全 codebase 沒有任何地方拿 `matched-user-id` 做授權判斷。
- 客戶 102 的使用者日後打開 SSP，反查時是用他自己的身分查，資料庫隔離會擋住客戶 131 的人 → 名字顯示空白；但**編號本身已寫進文件**，匯出 OSCAL 時會帶出去。

**另一條相鄰的路（不在本棒範圍，順帶記）**：SSP 人員新增／編輯 API（`ssp_party.py:21-22` 的 `matched_user_id`／`matched_org_unit_id`）接受呼叫端直接給任意整數，`ssp_party_app_service.py:98-110` 原樣寫進附註，不核這個人是不是同公司。一般客戶身分之後反查會被資料庫擋住，只能寫進一個「看不到名字的編號」，影響同上但更小。

**嚴重度 🟢 低（U12-4）**：需要平台管理員自己操作匯入、需要剛好有同 email 的別家帳號；後果是文件附註洩漏一個別家帳號的名字與 email 給客戶，而且只有平台管理員當下看得到名字。**修法**：`person_reconciler.py` 四處查詢比照組織比對加 `tenant_id`；SSP 人員 API 寫入 `matched_user_id` 前核對同公司。

### ⑧「有人守了一半」逐型對照

| 型態 | 本棒有沒有 | 在哪 |
|---|---|---|
| 只驗「你登入了沒」、沒驗「資料是不是你的」 | ✅ 有 | F2 讀取入口只驗登入（＝第 72 項）；U12-1 即時通知連線**連登入都沒驗**（但目前沒東西經過） |
| 只檢查列表、沒檢查單筆 | ❌ 沒有 | 守門服務讀寫七條路徑都各自補了 |
| 權限寫在 route 裝飾器、有第二支路由沒掛 | ⚠️ 有類似的 | 修正線四條寫入入口全補，但主線 `:131` 讀取端仍只驗登入（＝總表第 72 項） |
| 守門條件用 `or` 串起來其中一個永遠成立 | ❌ 沒有 | `_platform_admin_only` 兩個條件都綁群組名稱 |
| 例外處理把「查不到」與「沒權限」混成同一個回應 | ✅ 刻意的 | `get_system_configs` 對非平台管理員把受限列當作不存在，註解寫明理由（不讓泛用查詢整支失敗），合理 |
| 背景作業假設呼叫者是自己人 | ❌ 沒有 | seeder 用建立者身分寫，資料庫擋 |
| 註解寫「這裡刻意不檢查」但上一層沒真的檢查 | ⚠️ 有 | 套件 `SystemConfigListRoute` 註解「讀取只要登入即可；受 RLS 與宿主 service 兩層限制」——但宿主 service 只限制 AI／雲端硬碟兩組（＝第 72 項） |
| 判準抓對了層級、沒抓對「是哪一家」 | ✅ 有，**本棒新的型態** | U12-3 修正線總部守門 |

## 可信度分兩層

**第一層：工具正式報告**——驗證章 `verified`：研究員 2 派 2 回、3 候選去重後 3 條、9 票全投完、零漏投、零失敗。F1（高）與 F2（中）3:0 通過，**兩條都是總表既有項（第 74、72 項），淨新增 0**；C3 0:3 否決。密鑰掃描零發現。

**第二層：runner 人工**——本報告 U12-1～U12-6 全部是 runner 依卡片逐支開檔、實測或 DEV 唯讀查核追出來的，**沒有經過三人面板投票**：

- U12-1：開檔＋test client 實測重現；與工具 C3 同一件，面板否決理由（前端元件是死碼）我 grep 核對屬實，故評低。
- U12-3：開修正線檔案＋小程式實測判準。
- U12-4：開檔＋假服務實測查詢條件＋DEV 唯讀交易模擬兩種身分。
- U12-5：DEV 唯讀查 `pg_policies`。
- U12-6：開檔；修正線 diff 確認已修。
- 16 支檔案全部通讀。

DEV 查核時間：2026-09-25 15:30～15:45（本機 `guidant_ai_dev`，唯讀交易、`ROLLBACK` 結束，未寫入任何資料）。

## 執行概況

| 項目 | 數值 |
|---|---|
| 範圍 | 16 檔／1,921 行（`wc -l` 實跑） |
| 工具指令 | `/claude-security scan codebase ... --scope <16 檔> --effort low`（focus `attack-surface`） |
| run ID | `wf_a2018933-ffc` |
| 掃描版本 | `e1c9230334051bb91cc77a87e5a8b8ea404ea8c4`（`feature/review`） |
| 驗證章 | `verified`（stamp `CLAUDE-SECURITY-REVISION-e1c923033405-dirty.json`，`-dirty` 是工作區有平行線未 commit 的改動，不在本棒範圍） |
| 候選數／票數 | 3 候選 → 去重 3 → 面板 9 票全投完（F1 3:0、F2 3:0、C3 0:3），零漏投 |
| 研究員 | 派 2（研究 1＋密鑰掃描 1）回 2，**零失敗、零重試** |
| 耗時 | 約 1 小時 33 分（研究約 1 小時 22 分、面板約 11 分） |
| 子代理 token | 約 113 萬 |
