---
title: B2 掃描結果：公告鏈宿主接線（主專案）
---

# B2 掃描結果：公告鏈宿主接線（主專案）

> **掃描日期**：2026-09-08 起跑，2026-09-09 完成（跨兩次額度重置）
> **掃描版本**：compliance-manager-be `feature/FR-075` @ `582cbdfd`（工作目錄有未 commit 變更）
> **工具**：Claude Code `claude-security` plugin（`claude-security:scan` workflow）
> **範圍**：六條路徑、**33 個受版控檔案**——`api/bulletin/`、`app/bulletin/`、`domain/bulletin/`、`infra/bulletin/`、`di_containers/bulletin/`、`di_containers/dashboard_apis/bulletin.py`
> **effort**：`low`，**focus**：`attack-surface`
> **模型**：主 session Opus 5 (1M context)，研究員繼承
> **狀態**：✅ **驗證面板完整跑完（分兩輪＋一次續跑），stamp 為 `verification.status: verified`**——十條全部 3/3 全票，`unreviewed_candidate_sites: 0`
> **對應卡片**：CM-1610（母卡 CM-1609）

## 一句話結論

**範圍內找到 4 條問題，母卡標的十個重點裡有六個拿到明確結論。** 最值得看的是 F13：AI 儀表板有一條**不經過 route 層守門**的旁路，任何登入者都能透過它讀到全租戶的公告，包含未發布的草稿——而且那些內容還會被送到第三方 LLM。另外 6 條是密鑰專項撈到的**範圍外**發現（版控裡的硬編憑證），其中 F12（JWT 簽章金鑰）與 F15（installer 原廠 super admin 密碼）值得單獨看。

## 🔴 掃到什麼：十條一覽

**先看這張表就好**，每條的完整說明、程式碼行號與建議修法在後面各自的章節（點 ID 跳過去）。

| # | 嚴重度 | 這是什麼問題（白話） | 出事會怎樣 | 要先有什麼才打得到 | 位置 | 修正卡 |
|---|---|---|---|---|---|---|
| [F13](#f13medium-ai-儀表板旁路可讀全租戶公告) | 🟡 **MEDIUM** | AI 儀表板可以直接呼叫「取公告列表」這支程式，**這條路不經過網頁那層的任何守門**。而部門過濾的開關預設是關的 | 任何登入者叫出 AI 儀表板、要它「列出所有公告」，就拿到**全租戶的公告**——含別部門的、已停用的（草稿）、還沒到發布時間的。而且前三筆會**原文送給第三方 LLM** | 一個登入帳號（**不需要任何公告相關權限**）＋租戶有買 `ai-dashboard` 模組 | `bulletin_service.py:111`<br>`dashboard_apis/bulletin.py:10-19` | 待開 |
| [F16](#f16medium-改刪公告只驗你能不能改公告不驗你能不能改這一則) | 🟡 **MEDIUM** | 有「編輯公告」權限的人，可以改**或刪掉**任何一則公告，包含別部門發的。系統只問「你有沒有編輯權」，從不問「這則是不是你的」 | 別部門的公告被竄改或刪除。公告是「發給大家看、大家會相信」的內容。**另外**：更新時若沒帶部門欄位，會**靜默清空**這則公告的發送對象 | 一個有 `bulletin.update` 或 `bulletin.delete` 的角色（部門公告編輯者就是這種）＋知道目標公告的 uid | `bulletin_service.py:162`（改）<br>`bulletin_service.py:180`（刪，連 user 都沒收）<br>`bulletin_service.py:165`（清空對象） | 待開 |
| [F17](#f17low-單筆讀取完全不做範圍檢查) | ⚪ LOW | 用網址直接開一則公告時，系統**不檢查任何東西**——不看部門、不看是不是草稿、不看有沒有到發布時間、不看是不是已刪除 | 調部門之後，舊部門的公告用舊網址還是打得開；還沒公告的公告可以提前看到。**只有 uid 不可猜這件事在擋** | 一個登入帳號 ＋ **從別處知道 uid**（uid 是 uuid4 猜不到，但會出現在列表回應與前端網址上） | `bulletin_service.py:119` | 待開 |
| [F18](#f18low-列表對沒有部門的使用者直接放行) | ⚪ LOW | 公告列表的部門過濾，**碰到「沒有部門」的帳號會直接不過濾、全部給你看**。而且要不要過濾的開關，是**呼叫端自己在請求裡傳的** | 沒有部門的帳號打列表，拿到**全租戶公告**（含草稿、含過期），以及它們的 uid——這些 uid 正好餵給上面 F17 那條 | 一個登入帳號 ＋ 該帳號在當前租戶沒有部門（`org_units=[]`，或切到沒有部門的租戶） | `bulletin_service.py:79`（判斷式）<br>`bulletin_route.py:29-35`（開關來自請求） | 待開 |
| [F12](#f12medium範圍外-jwt-簽章金鑰在版控裡34-處) | 🟡 MEDIUM<br>**⚠️ 範圍外** | **簽發登入憑證用的那把金鑰**，明文躺在版控的對話紀錄裡，34 處。經比對與現行 `.env` **完全一致**，是還在用的金鑰 | 拿到這把金鑰就能**自己偽造任何人的登入憑證**——任何使用者、任何租戶、任何角色。不需要密碼、不需要 MFA。租戶隔離（RLS）也是從憑證推出來的，一併失效 | **只要讀得到 repo**，不需要任何網路位置 | `docs/conversation-history/2026-05-20/ssp-import-export-phase2/1802c4fb-a3-writing-plans.md:6908` | **併 CM-1607**<br>（降衛生類，見裁決段）|
| [F15](#f15medium範圍外-installer-每套部署都種同一組原廠-super-admin-密碼) | 🟡 MEDIUM<br>**⚠️ 範圍外** | 安裝程式在**每一套部署**都種下同一組最高權限帳號密碼（bcrypt hash 寫死在 SQL 裡），而且**不要求首次登入改密碼** | 把那個 hash 拿去離線爆破一次，就拿到**所有客戶安裝**的 super admin。這個帳號還被 `root_admin_guard.py` 特別保護、刪不掉停不掉 | 讀得到 repo（拿 hash）或拿到廠內密碼文件 ＋ 連得到某套部署的登入端點 | `scripts/init/06-admin.sql:41-42`（hash）<br>`:69-82`（寫入） | **暫不開卡**<br>（決策者裁記錄待議）|
| [F1](#f1medium範圍外-資料庫-cmmgr-密碼在版控裡) | 🟡 MEDIUM<br>**⚠️ 範圍外** | 資料庫管理帳號 `cmmgr` 的密碼寫在文件裡。經比對與 `.env` 一致、是活的。**這個帳號是 `BYPASSRLS`** | 直接連進 DEV 與**出貨基線庫**，**繞過所有租戶隔離**。基線庫是出貨映像檔的來源，寫進去會流進客戶 | 讀得到 repo ＋ 連得到 `192.168.50.188:25432`（內網或 VPN） | `docs/analysis/2026-05-28-poc-db-migration-plan.md:73` | 待開 |
| [F2](#f2medium範圍外-四家-llm-的-api-金鑰在九個檔案裡) | 🟡 MEDIUM<br>**⚠️ 範圍外** | OpenAI／Anthropic／Google／LangChain 的 API 金鑰，完整躺在九個對話紀錄檔裡 | 用公司帳號燒錢；LangSmith 那把還能**讀走歷史追蹤紀錄**，裡面例行含應用提示詞與客戶資料 | 讀得到 repo。（commit `5746cef1` 宣稱已於 2026-09-08 撤銷，**但那次只刪了 `.env.test`，這九個檔沒動**，且撤銷與否無法從程式碼查證） | `docs/conversation-history/2026-04-28-to-04-30-survey-answer-arc/part-verbatim-01-of-03.md:3651` 等九檔 | 待開 |
| [F3](#f3medium範圍外-nexus-與-minio-憑證在交接文件裡) | 🟡 MEDIUM<br>**⚠️ 範圍外** | 私有套件庫 Nexus 的帳密、與 MinIO 的金鑰，寫在一份交接文件裡 | **這是供應鏈的根**——有 Nexus 發布權就能推一個含後門的 `jedi-common`，下次 build 自動裝進來執行。MinIO 那把能讀寫稽核證據桶 | 讀得到 repo ＋ 連得到 `192.168.50.171` ＋ 該帳號仍有發布權 | `docs/features/FR-039-2606-distributed-file-agent/handoff/2026-06-18-FR039-handoff.md:89` | 待開 |
| [F14](#f14medium範圍外-管理員密碼當成環境變數預設值) | 🟡 MEDIUM<br>**⚠️ 範圍外** | 一支 migration 腳本寫「環境變數沒設就用這個密碼」——**而那個「這個」是真的管理員密碼** | 拿到 repo 就有一組可用的管理員帳密。變數忘了設時**靜默用真密碼登入**，操作者不會被告知 | 讀得到 repo ＋ 連得到某個該帳號仍用這組密碼的環境 | `scripts/migrate_2026-08-09_fr062_existing_tenant_licenses.py:80` | 待開 |

### 🔴 首腦驗收裁決（2026-09-09，決策者親裁兩條）

驗收時對兩條範圍外發現做了獨立查證，結論與報告原判不同，**以本段為準**：

**F12（JWT 簽章金鑰）→ 降為衛生類，不獨立開卡，併入 CM-1607。**

報告判 MEDIUM、首腦驗收時一度主張升 HIGH（理由：實測該金鑰與現行 `.env` 一致、`git grep` 命中本機 30 檔／`origin/main` 31 檔、橫跨 14 個 commit，最早由 `45d5b8ac` 帶入）。**但決策者提出的問題推翻了這個判級**：安裝時是不是動態產生？

查證結果——**是**。`scripts/installer/install.sh:1252` 每套安裝各自跑 `JWT_SECRET_KEY="$(gen_key)"`，而 `gen_key`（`:211`）是 `openssl rand -base64 32`，密碼學等級隨機；同區塊的 `DRIVE_TOKEN_ENCRYPTION_KEY`／`DETECTION_TOOL_ENCRYPTION_KEY` 同理。變數名也對得上：`config/config_loader.py:81` 讀的正是 `JWT_SECRET_KEY`（平鋪格式，兼容舊的 `JWT_SECRET.jwt_secret` JSON），不是死參數。

**所以外流的是開發機那把，不是任何客戶的。** 影響範圍從「所有客戶安裝」縮到「我們自己的 DEV」，觸發前提多一道「連得到 DEV（內網／VPN）」。這與 CM-1608 的裁決形狀相同（「那是自己的測試機，密碼跟部署的無關」），適用同一判準。

**處置**：版控裡那 31 個檔的字串仍要清，但性質是**打掃不是堵漏**——留著會讓往後每次掃描都撈出這批假陽性（正是 CM-1607 的開卡理由）。故**併入 CM-1607**，不另開卡。**DEV 那把金鑰決策者裁不需更換。**

> **教訓（寫給後續棒次）**：判密鑰外洩的嚴重度，「這把是不是活的」與「散在幾個檔」都不是決定性的那一問，**「客戶端用的是不是同一把」才是**。首腦驗收時查了前兩者就升級，漏了第三者，由決策者當場問出來。**下次遇到任何硬編／外洩憑證，先查 installer 有沒有動態產生。**

**F15（installer 原廠 super admin 密碼）→ 維持記錄，暫不調整。**

決策者 2026-09-09 裁：「目前應該是都一樣，這個先記錄起來就好，之後看怎麼調整。」**確認現況即每套安裝種同一組**，不強制首次登入改密碼。與 F12 相反——JWT 金鑰動態產生了，這個沒有。**本 arc 不開修正卡、不派工**，留待日後一併處理安裝期憑證策略。

---

**被面板否決的一條**：`.env.sample` 裡的 Cloudflare Turnstile 測試金鑰，面板 **0:2 判為 false positive**——installer 寫死 `TURNSTILE_ENABLED=false`，沒有任何出貨路徑會走到那個值。判得對，不列入。

### ⚠️ 這份結果的可信度

**「這十條存在嗎」與「只有這十條嗎」要分開回答。**

**存在：可信度高於前幾棒。** 十條**全部 3/3 全票**通過三人對抗式面板（reachability／impact／defenses 各一票），`verification.status: verified`、`unreviewed_candidate_sites: 0`、`incomplete_panel_candidates: 0`、`lostCandidates` 空。**沒有任何一條是靠部分票通過的**——這一點在中途兩次撞額度之後仍然成立，過程見下方「中斷與續跑」段。四條範圍內的發現，首腦另已**逐條開檔核對行號與程式碼形狀**（見各章節末的核對註記），全部屬實。

**「只有這十條」：不可信，兩層。**

第一層是通例：`low` effort 單次快篩，沒有 inventory、沒有威脅模型、沒有廣度掃蕩，工具自己把 `completenessCheckOutcome` 標成 `not-applicable`。**33 檔裡有 10 個是空的 `__init__.py`，實質內容 23 檔**，範圍很小，但小不等於掃得深。

第二層是這一棒特有的：**六條範圍外發現不代表那些目錄被掃過。** F1／F2／F3／F12／F14／F15 是密鑰專項順帶撈到的，**`docs/`、`scripts/` 兩棵樹從來不是掃描目標**。這六條不能讀成「那兩處只有這些問題」——那兩處等於沒掃過。

另外，本輪 `coverage.research` 是 `null`，**沒有機器產生的「哪些檔真的被讀到結論」清單**。範圍內含哪 33 檔是確定的，每一檔是否被讀完則無獨立佐證。

同樣地，掃描過程**沒有執行任何被掃的程式碼**——沒跑測試、沒發攻擊、沒拿任何憑證去連任何服務。所有結論都來自讀原始碼。

## 執行概況

| 項目 | 數字 |
|---|---|
| 研究員派出 / 回報 | 2 / 2 |
| 候選發現 | 13 條原始 → 去重後 **11 條** |
| 面板票數 | **38** |
| 面板審過的發現 | 10（`panel_reviewed_findings: 10`，`panel_quorum_findings: 10`）|
| 未投票候選 | **0**（`unreviewed_candidate_sites: 0`）|
| 面板不完整候選 | **0**（`incomplete_panel_candidates: 0`）|
| **驗證輪次** | **2**（＋一次同輪續跑）|
| 被面板降級 | 4 條（F1、F12 HIGH→MEDIUM；F17、F18 MEDIUM→LOW）|
| 被面板否決 | 1 條（Turnstile 測試金鑰，0:2）|
| 總耗時 | 約 14 小時 8 分（50,871 秒，含等待兩次額度重置）|

---

## 🔴 中斷與續跑：兩次撞額度，外加一個差點吃掉三條發現的併檔陷阱

**這一棒的過程比結果更值得記下來。** 前後撞了兩次 session 額度上限，還踩到一個工具本身的邊界情況，三者都沒讓結果失真，但第三個差一點。

### 第一次中斷（第一輪面板）

第一輪面板驗到一半撞上額度上限（凌晨 5:20 重置那次），11 個候選裡**有 8 個面板不完整**——其中 4 個是零票、2 個一票、2 個兩票。工具沒有拿部分票充數，全部記進 `adversarialCasualties` 並**交棒下一輪**：

```
F4: panel incomplete (2/3 voters returned), handed to the next verification run
F6: panel incomplete (0/3 voters returned), handed to the next verification run
...（共 8 條）
```

這次中斷之前，**研究階段已經完整跑完**（2 派 2 回），所以 33 檔是真的讀過了、候選清單完整。斷的全是投票。

### 第二次中斷（第二輪面板）

額度重置後跑第二輪，續驗那 8 條。**又撞一次額度**（上午 10:20 重置那次），這次是 4 條沒湊滿票，而工具這輪的處置是 `dropped without a verdict`——**直接丟棄，因為沒有下一輪可交棒**。

那 4 條裡有 3 條的已投票數是全員 TRUE_POSITIVE，且其中兩條正是母卡重點 ③ 與 ⑤。**如果就這樣出報告，這兩個重點會在報告上留白**，正是母卡明文警告的「不要留白讓人誤以為乾淨」。

處置：額度重置後用 `resumeFromRunId` **續同一個 run**，已完成的 19 票從快取回放、只重派掛掉的 5 票（`C8:v2`、`C9:v1`、`C9:v2`、`C10:v3`、`C11:v3`）。25 個 agent 全數回報、零失敗。**票數仍由工具程式碼計算，不是人工拼湊。**

### 🔴 差點白工的地方：併檔把完整結果當成重複丟掉

續跑完成後把結果併回 run 目錄，`save_result.py` 回報 **7 findings** 而不是預期的 10 條。

**原因**：run 目錄已經停在 `shard 2`，而完整版續跑回來的也是 `shard 2`。`merged()` 判定 `scan.coverage.verificationRun == run.chain.shard` 成立，於是**保留了先前那份殘缺的 run 2、丟棄完整版**。

**為什麼難發現**：兩份結果的 finding ID 互相衝突且意義不同——殘缺版的 `F15` 是「單筆讀取不做檢查」，完整版的 `F15` 是「installer 原廠密碼」。光看數字對不出問題，要逐條比對標題才看得出來。

**修法（沒有手改任何紀錄）**：備份 run 目錄 → 刪掉 `findings.json`／`votes.json`／`coverage.json`／`candidates.2.1.json` 退回 run 1 之前 → 用 `save_result.py` 重放 run 1 的原始 output → 再折入完整 run 2 的 output。三份紀錄與票數全程由工具產生。

**給後續棒次的教訓**：
- **續跑後一定要核對併檔結果的條數與標題**，不能只看 `save_result.py` 印的數字。它印「recorded」不代表它折進去了。
- `save_result.py` 的 output 檔內含 `runDir` 絕對路徑，**不能換目錄重放**（試過會被拒絕）。要重建只能在原地做，所以**動手前先備份 run 目錄**。
- 兩份同 shard 的結果會靜默擇一，**不會報錯**。

---

## 母卡十個重點逐項回答

母卡列了十個重點，明文要求「逐項回答有沒有結論，沒觸及的要明講」。**六項有明確結論、兩項部分回答、兩項本棒未觸及。**

| # | 重點 | 結論 |
|---|---|---|
| ① | 兩條讀取 route 沒有 capability 守門 | ✅ **確認是漏守門，不是設計如此**。見下方專節，這是本棒最硬的一項查證 |
| ② | 單筆讀取完全不做範圍檢查 | ✅ **確認**，即 [F17](#f17low-單筆讀取完全不做範圍檢查)。嚴重度依 B1 的 uuid4 結論下調為 LOW |
| ③ | 列表部門過濾反直覺、開關由呼叫端傳 | ✅ **確認**，即 [F18](#f18low-列表對沒有部門的使用者直接放行)。三條路都追了，見該節 |
| ④ | 查詢條件「拿物件欄位當白名單」動態組裝 | ⚠️ **部分回答**。工具沒有把它報成獨立發現，但 F13 的成因正是 `BulletinQueryEntity.__init__` 把 `org_unit_id` 吞進 `**kwargs` 而不轉成過濾條件。**「外部鍵能不能落進 `__dict__` 進而改變查詢語意」這條完整路徑未經獨立查證** |
| ⑤ | 寫入只驗 capability、不驗歸屬 | ✅ **確認**，即 [F16](#f16medium-改刪公告只驗你能不能改公告不驗你能不能改這一則)。`delete_bulletin(uid)` 確實連 `user` 參數都沒有（`:180`）|
| ⑥ | 更新時先刪光部門關聯再重建 | ✅ **確認且打得到**。`:165` 無條件 `delete_by_bulletin_id`，之後才看 `kwargs.get("org_units")`。已併入 F16 的影響描述 |
| ⑦ | `content` 是不驗證的 `Raw` 欄位（儲存型 XSS）| ❌ **本棒未觸及**。工具沒報，且 FE 渲染方式需開 FE repo 查證才能下結論——母卡明文要求「不要憑猜報」，故不猜。**這一項等於沒查** |
| ⑧ | AI 儀表板的旁路入口 | ✅ **確認，且是本棒最重要的發現**，即 [F13](#f13medium-ai-儀表板旁路可讀全租戶公告) |
| ⑨ | 審計欄位的 SQL 子查詢 hybrid_property | ❌ **本棒未觸及**。工具沒報，首腦亦未獨立查證跨 users 表的 RLS 繞過問題。**這一項等於沒查** |
| ⑩ | DB 現況（RLS 是否 enabled、四動作對稱性）| ✅ **已查證**，見下方專節。有一個母卡沒預期到的發現 |

### ① 的答案：`bulletin.read` 存在、被設計、也被設定了——只是沒有任何 route 執行它

母卡說「判準不要只看程式碼註解——查 DB 的 capability 目錄裡有沒有 `bulletin.read` 這個項」。查了，答案比預期明確：

**能力點存在**（`scripts/init/04-seed-core.sql:218`）：
```sql
INSERT INTO public.capabilities (id, name, ...) VALUES (3, 'bulletin.read', 'bulletin', 'read', 'bulletin read access', false)
```

**而且被綁到公告管理頁的路由上**（`04-seed-core.sql:359-362`，`route_id=7` 即 `bulletin-manage`）：
```sql
VALUES (7, 3, 'ALL')   -- bulletin.read，ALL 規則
VALUES (7, 1, 'ANY')   -- bulletin.create
VALUES (7, 4, 'ANY')   -- bulletin.update
VALUES (7, 2, 'ANY')   -- bulletin.delete
```

**系統設計文件甚至明文寫了判定規則**（`docs/system-design/scripts/generate_permission_system_docx.py:471-479`）：

> 使用者必須有 `bulletin.read`（ALL 規則）／ 加上 create、update、delete 任一（ANY 規則）
> read 一律設 ALL：必有讀權限才能看到選單；缺就完全不可見

**但 `bulletin.read` 這個字串在整個 Python 程式碼裡，除了那份文件產生腳本以外，一次都沒出現。** 兩條讀取 route 只掛 `@jwt_required()`。

**結論：這不是「設計成全體登入者可見」，而是明確的漏守門。** 能力點被定義、被綁定、被寫進設計文件，唯獨沒有被任何一行程式碼檢查。這與 CM-1585／CM-1589 完全同型。

補充：另有 `route_id=6`（`bulletin-list`，一般使用者的公告列表頁）綁的是 `bulletin-list.read`（capability id 82），是另一個能力點，同樣沒有任何程式碼檢查它。

### ⑩ 的答案：`bulletins` 的 RLS 齊全，但 `bulletin_org_units` **完全沒有 RLS**

母卡要求唯讀查證兩張表。以出貨 schema（`scripts/init/02-schema.sql`）查證結果：

**`bulletins`**：✅ RLS 已啟用（`:26800`），四個動作的 policy 齊全（`:26806/26813/26820/26827`）。INSERT policy 確實與其餘三支不對稱——它多了 `app_org_allowed_for_session(org_unit_id)` 這個條件，**方向是更嚴格**，屬設計而非缺陷（與 B1 移交的線索③ 一致）。

**`bulletin_org_units`**：🔴 **整張表沒有 `ENABLE ROW LEVEL SECURITY`，也沒有任何 policy。** 全檔只有 11 處提到這張表，全是 CREATE TABLE、PK 與兩條 FK。

這正是母卡提到的 CM-1559 形狀（「有 policy 但 RLS 沒啟用」）的更極端版本——這張表連 policy 都沒有。**實際影響有限**：這是關聯表，只存 `(bulletin_id, org_unit_id)` 兩個整數，跨租戶讀到也只是一組數字，且 `bulletins` 本身的 RLS 會擋住實際內容。但它意味著**「公告發給哪些部門」這個關聯本身不受租戶隔離保護**，且與 F16 的「靜默清空對象」組合時，DB 層沒有第二道防線。

**這一項是母卡沒預期到的**（母卡只要求確認「是否 enabled、四動作是否對稱」，預期的答案形狀是「有或沒有」，不是「整張表不在保護範圍內」）。

---

## 各條發現詳述

### F13（MEDIUM）：AI 儀表板旁路可讀全租戶公告

**面板**：3/3 全票｜**信心**：high｜**位置**：`app/bulletin/service/bulletin_service.py:111`

**白話**：公告有兩條路可以讀。一條是網頁走的 route，那條至少還有登入檢查；另一條是 AI 儀表板走的，**它直接呼叫 service，完全不經過 route 那層**。而部門過濾的開關在這條路上永遠是關的。

**技術細節**：`di_containers/dashboard_apis/bulletin.py:10-19` 把 `bulletin.get_bulletins` 註冊給 jedi-ai-dashboard。`DataAPIService._execute_api_call` 呼叫它時傳 `params={}`，所以 `query_entity.auth` 是 falsy，`:107` 的 `if query_entity.auth and user.org_unit_id` 不成立，`:111` 的查詢就只帶 `is_delete=0` 跑出去，回傳全租戶公告。

註冊檔宣告的 `optional_params: [title, status, org_unit_id]` 有誤導性——`org_unit_id` 傳進去會被 `BulletinQueryEntity.__init__` 的 `**kwargs` 吞掉，**不會變成過濾條件**（這正是重點 ④ 的形狀）。

**額外影響**：`AIDashboardAppService._ask_ai_to_design_layout` 會把結果的前三筆**原文送給設定的第三方 LLM**。所以這不只是越權讀取，還是資料外流。

**觸發前提**：一個登入帳號（不需任何 `bulletin.*` 能力點）＋ 租戶有 `ai-dashboard` 授權 ＋ 第一階段的 LLM 選中這支 API（攻擊者用自由文字 `prompt` 誘導）。

**建議修法**：不要依賴呼叫端傳的 `auth` 旗標。讓 `get_bulletins` **無條件**從 `get_user_context()` 推導可見範圍（部門 ＋ `enable=1` ＋ 發布/過期時間窗），與 `get_bulletins_and_pager` 在 `auth` 為真時的行為一致；並在 dashboard 註冊路徑補上 `bulletin.read` 檢查。

**首腦核對**：✅ 已開檔核對。`:107` 判斷式、`:111` 查詢行、註冊檔 `:10-19` 三處均與報告一致。

### F16（MEDIUM）：改／刪公告只驗「你能不能改公告」，不驗「你能不能改這一則」

**面板**：3/3 全票｜**信心**：medium｜**位置**：`app/bulletin/service/bulletin_service.py:162`

**白話**：系統只問「你有沒有編輯公告的權限」，從來不問「這一則是不是你的、是不是你部門的」。有權限的人可以改或刪任何一則。

**技術細節**：`uid` 來自網址路徑，唯一的守門是 route 層的 `@require_capability("bulletin.update")`（`bulletin_route.py:55`）與 `@require_capability("bulletin.delete")`（`:67`）。`update_bulletin` 拿到 uid 直接組 entity 更新（`:158-162`）。**`delete_bulletin(self, uid)`（`:180`）連 `user` 參數都沒收**，所以就算想檢查也無從檢查。

**連帶的靜默清空**（母卡重點 ⑥）：`:165` 無條件 `delete_by_bulletin_id(bulletin.id)`，之後才 `kwargs.get("org_units")`。**沒帶 `org_units` 的部分更新會把發送對象清空**，公告可見範圍因此改變。與 CM-1588 同類。

**觸發前提**：一個有 `bulletin.update` 或 `bulletin.delete` 的角色 ＋ 知道目標 uid。seed 只把這些能力點給 Administrator，但角色矩陣允許租戶管理員自建這種角色（部門公告編輯者就是典型）。

**建議修法**：resolve 出公告之後，在 service 層斷言操作者是建立者、或與該公告的 `org_units` 有交集，再套用更新或刪除；把操作者傳進 `delete_bulletin` 以便做同樣斷言。部門關聯的重建改為**只在有帶 `org_units` 時才動**。

**首腦核對**：✅ 已開檔核對。`:162`、`:165`、`:180` 三處與報告一致；`delete_bulletin` 的簽章確認只有 `(self, uid)`。

### F17（LOW）：單筆讀取完全不做範圍檢查

**面板**：3/3 全票｜**信心**：high｜**位置**：`app/bulletin/service/bulletin_service.py:119`｜**面板降級**：MEDIUM → LOW

**白話**：用網址直接開一則公告，系統什麼都不檢查——不看部門、不看是不是草稿、不看到沒到發布時間、不看是不是已刪除。列表那條費心做的過濾，在這條路上完全繞過。

**技術細節**：`BulletinRoute.get`（`bulletin_route.py:46-49`）只掛 `@jwt_required()`，把 uid 直接給 `get_bulletin(uid)`，後者呼叫 `get_bulletin_by_uid`，最終是 `BaseRepositoryImpl.get_by_uid` 的 `filter_by(uid=_uid).first()`，**沒有任何範圍述詞**。

**🔴 嚴重度依 B1 結論調整**：母卡原本擔心 uid 可枚舉。**B1 已確認 `generate_uuid` 用 `uuid.uuid4()`，122-bit 密碼學隨機、不可預測**，所以列舉不可行。真正的風險路徑是**「uid 從別處洩漏」**——而 uid 確實會出現在列表回應與前端網址（`/bulletin/bulletin-view?uid=`）上，且 F18 那條會讓沒有部門的帳號一次拿到全部 uid。面板獨立得到同樣判斷，三票一致降為 LOW。

**攻擊情境**：使用者從 A 部門調到 B 部門，手上還留著 A 部門公告的 uid（舊的列表回應、或收藏的網址）。調動後列表已經不回傳那則，但 `GET /api/1.0/bulletin/<uid>` 照樣回傳完整內容。同樣的請求也能讀到 `release_time` 還沒到的公告。

**建議修法**：resolve 出公告後，套用與列表相同的可見性述詞（部門或建立者 ＋ `enable=1` ＋ `is_delete=0` ＋ 發布/過期時間窗）再回傳。

**首腦核對**：✅ 已開檔核對。`:119` 與 `bulletin_route.py:46-49` 一致，route 確實只有 `@jwt_required()`。

### F18（LOW）：列表對「沒有部門」的使用者直接放行

**面板**：3/3 全票｜**信心**：medium｜**位置**：`app/bulletin/service/bulletin_service.py:79`｜**面板降級**：MEDIUM → LOW

**白話**：公告列表的部門過濾寫成一個 if/else。**`if` 要兩個條件同時成立才過濾；`else` 又只在「有部門」時才限縮成本人建立的**。所以「沒有部門」的帳號兩邊都不套過濾，直接看到全部。而且要不要過濾的開關，是呼叫端自己在請求 body 裡傳的。

**母卡要求追的三條路，逐條回答**：

**(a) `auth` 傳 false 走 else 分支會看到什麼**：若使用者有部門 → 看到自己建立的公告（`owner_created_user` 限縮，`:88`）。若沒有部門 → **看到全部**。

**(b) `user.org_unit_id` 為 None 的是哪種帳號**：`UserContextDTO.org_unit_id` 是 `org_units[0].id if org_units else None`（依當前租戶過濾後取第一個），且 `users.org_unit_id` 可為 NULL。所以**建立時 `org_units=[]` 的帳號**、或**切換到自己沒有部門的租戶**的帳號都會是 None。註解說的「顯示全部公告」範圍就是整個租戶。

**(c) 兩者組合能不能讓一般使用者看到全部**：**能**。只要帳號沒有部門，不論 `auth` 傳什麼都看得到全租戶公告，含 `enable=0` 草稿與過期項；再傳 `filters.is_delete = 1`（schema 接受的欄位）還能撈出軟刪除的。

**關於母卡提醒的 marshmallow `default=`**：母卡提醒「`default=` 是序列化預設值、不是反序列化預設值」。實測結果是這一點**不影響結論**——`auth` 完全不傳時 `BulletinQueryEntity` 收到的是 falsy，走 else 分支，與傳 `false` 同路。真正的問題不在 `auth` 的預設值，而在**兩個分支都沒有涵蓋「沒有部門」這個情況**。

**建議修法**：把「沒有部門」視為 fail-closed 而非 fail-open；可見性模式由**伺服器依呼叫的 route 判定**，不要從請求 body 的布林值取；管理檢視也應套用 `enable`／發布時間窗過濾，而非只在 `auth` 路徑套。

**首腦核對**：✅ 已開檔核對。`:79`／`:88`／`:90` 與報告一致；`bulletin_route.py:29-35` 確認 `filters` 由 `request.get_json()` 手動 `load()` 後原樣傳入。

### F12（MEDIUM，範圍外）：JWT 簽章金鑰在版控裡，34 處

**面板**：3/3 全票｜**面板降級**：HIGH → MEDIUM｜**位置**：`docs/conversation-history/2026-05-20/ssp-import-export-phase2/1802c4fb-a3-writing-plans.md:6908`

簽發登入憑證的金鑰（`JWT_SECRET` → `config/config_loader.py:81` → `Config.JWT_SECRET_KEY`）明文躺在對話紀錄裡，共 34 處。**面板比對過與工作目錄 `.env` 完全一致，是現行金鑰不是舊樣本。**

拿到它就能偽造任何使用者、任何租戶、任何角色的憑證，不需密碼或 MFA；`build_user_context()` 會據此產生 `allowed_tenant_paths`，RLS 也一併失效。**只要讀得到 repo 就能做，不需任何網路位置。**

**修法**：每個環境輪替 JWT 金鑰、視為已燒毀；加 pre-commit 密鑰掃描；對話紀錄歸檔前必須遮罩 `.env` 內容。

### F15（MEDIUM，範圍外）：installer 每套部署都種同一組原廠 super admin 密碼

**面板**：3/3 全票｜**位置**：`scripts/init/06-admin.sql:41-42`（hash），`:69-82`（寫入）

`init.sh` 每次全新安裝都會跑的 `06-admin.sql`，插入一個 `is_super_admin` 的 `admin` 帳號，**bcrypt hash 與 salt 是寫死的常數**。所以**每一套客戶安裝都是同一組密碼**，而且那份密碼材料在版控裡。這個帳號還被 `root_admin_guard.py` 特別保護（刪不掉、停不掉），且 seed **不建立強制改密碼的要求**。

離線爆破那個 hash 一次，就拿到所有安裝的 super admin。

**修法**：改為每套安裝各自產生密碼（`install.sh` 對 DB 帳號已經這樣做了），把 hash 傳進 seed 而不是寫死；並要求首次登入強制改密碼。

### F1（MEDIUM，範圍外）：資料庫 `cmmgr` 密碼在版控裡

**面板**：3/3 全票｜**面板降級**：HIGH → MEDIUM｜**位置**：`docs/analysis/2026-05-28-poc-db-migration-plan.md:73`

`cmmgr` 與 `cm_app` 的密碼明文寫在數十份文件裡，面板比對過與 `.env` 的 `DB_PASSWORD` 一致、是活的。**`scripts/init/00-cluster.sql:31` 定義 `cmmgr` 為 `BYPASSRLS`**，所以拿它連線等於繞過全部租戶隔離。該庫又是 `scripts/init/gen_*.sh` 的 dump 來源，寫入會流進出貨基線。

**修法**：所有主機輪替 `cmmgr`／`cm_app` 密碼；文件裡的字面值改成專案 CLAUDE.md 已規定的「請查 `.env`」寫法；歷史清除或視為已燒毀。

### F2（MEDIUM，範圍外）：四家 LLM 的 API 金鑰在九個檔案裡

**面板**：3/3 全票｜**位置**：`docs/conversation-history/2026-04-28-to-04-30-survey-answer-arc/part-verbatim-01-of-03.md:3651` 等九檔

完整的 `sk-proj-…`／`sk-ant-api03-…`／`AIzaSy…`／`lsv2_pt_…` 逐字出現在九個受版控 markdown 裡。**commit `5746cef1` 的訊息宣稱這四把已於 2026-09-08 撤銷，但那次清理只刪了 `.env.test`，`docs/conversation-history/` 下這九份至今仍在 HEAD。** 撤銷與否無法從程式碼查證。

**修法**：到各家 provider 主控台**實際確認**撤銷（不要採信 commit 訊息），然後把值從這九份 dump 清掉；把遮罩步驟加進對話歸檔 SOP。

### F3（MEDIUM，範圍外）：Nexus 與 MinIO 憑證在交接文件裡

**面板**：3/3 全票｜**位置**：`docs/features/FR-039-2606-distributed-file-agent/handoff/2026-06-18-FR039-handoff.md:89`

一份受版控的交接文件寫明了拉取所有 `jedi-*` 套件用的 Nexus 私有 PyPI 帳密，同檔還嵌了共用 dev 租戶儲存設定的 MinIO secret key。**該文件自己註明「憑證檔是 gitignored」——但被寫在文件裡的那組憑證本身就在 commit 裡。**

**這是供應鏈的根**：有 Nexus 發布權就能推一個高版號、含後門的 `jedi-common`／`jedi-iam`，等下一次 `poetry update` 或 `build_all.sh` 在 build 機執行時載入。

**修法**：輪替 Nexus 帳號（且改用範圍受限的 deploy token 而非 `admin`）與 MinIO 金鑰；把字面值從 handoff markdown、`docs/features-site/docs` 副本、以及 `docs/features-site/site/` 下產生的 HTML 一併清除。

### F14（MEDIUM，範圍外）：管理員密碼當成環境變數預設值

**面板**：3/3 全票｜**位置**：`scripts/migrate_2026-08-09_fr062_existing_tenant_licenses.py:80`

`SEED_PASS = os.getenv("FR062_SEED_PASS", "<真密碼>")`——環境變數沒設時，腳本會**靜默拿真密碼**去打真的 `/login` 端點。這種寫法同時毀掉兩件事：憑證外洩，以及「看起來像是外部化設定」的假象（操作者忘了設變數不會收到任何警告）。

**修法**：拿掉預設值，`FR062_SEED_PASS` 未設時直接失敗；把字面值從 `docs/claude/memory/` 與各 feature handoff 清掉。

---

## 給修正卡的建議切法

依決策者 2026-09-08 裁定，**掃描結果產生的修正卡先不派**，由 PM 收集所有掃描結果後統一安排。以下是建議的切法，供 PM 參考：

| 建議卡 | 內容 | 理由 |
|---|---|---|
| **公告授權補強** | F13 ＋ F16 ＋ F17 ＋ F18 ＋ 重點 ① 的 `bulletin.read` 守門 | 四條全在 `bulletin_service.py`、改動相鄰，且共同的根因是「可見範圍判定散在各方法、且部分由呼叫端決定」。分開修會改到同一批行 |
| **憑證輪替與清除** | F1 ＋ F2 ＋ F3 ＋ F12 ＋ F14 ＋ F15 | 全部是版控內憑證，處置動作同型（輪替 → 清字面值 → 加 pre-commit 掃描）。**F12 與 F15 建議優先**——前者是認證體系的根，後者影響每一套客戶安裝 |
| **`bulletin_org_units` RLS** | ⑩ 的發現 | 獨立的 schema 修正，走 `sql-migration` SOP |

**兩項未查的要另外安排**：重點 ⑦（`content` 的儲存型 XSS，需開 FE repo 查渲染方式）與重點 ⑨（審計欄位 subquery 是否繞過 users 表 RLS）。**這兩項本棒等於沒查，不能當作乾淨。**

## 座標

- 工具原始產物：`CLAUDE-SECURITY-20260908-134256/`（含 `.jsonl`／`.sarif`／stamp，該目錄有自己的 `.gitignore` 不入版控）
- stamp：`CLAUDE-SECURITY-REVISION-582cbdfda7b0-dirty.json`，`verification.status: verified`
- 同 arc 前一棒：[`scan-B1-package-core.md`](scan-B1-package-core.md)（CM-1611，套件本體）
- 報告格式參照：`../FR-078-2609-notification-security-scan/scan-N2-host-wiring.md`
- 首腦手冊：`.claude/skills/security-scan-lead/SKILL.md`
