O2 檢查結果:資源庫、公版範本、匯出

O2 檢查結果:資源庫、公版範本、匯出

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


§1

🔴 一句話結論

派工時預判的那支「整支檔沒有任何權限檢查」的 530 行程式,確實有洞,而且是這批到目前為止最嚴重的一條。

一家公司的管理員,可以改掉、甚至清空另一家公司(或原廠公版)合規範本的控制項清單,並把該範本底下所有「我們公司怎麼做到這條」的文字記錄整批刪除。系統裡明明已經有一支專門防這件事的警衛程式,而且它的說明還寫明了「不能只靠資料庫擋」——但這條路徑沒有叫它。

另外還有一條高風險:一組正在用的資料庫帳號密碼,被完整寫死在一支受版控的腳本裡(主機、埠號、資料庫名、帳號、密碼五樣齊全)。


§2

這一棒在檢查什麼

三塊功能,共 11 支檔、1,772 行:

  • 資源庫:客戶自己建的合規範本庫(選一套框架 → 系統複製一份控制項目錄給你 → 你勾選哪些控制項適用)
  • 公版範本:原廠預先做好、所有客戶都看得到的範本
  • 匯出:把系統安全計畫輸出成 Word/PDF/ODT,其中包含整批唯一一支會去呼叫外部程式(LibreOffice)的檔

選這一棒先掃,是因為派工前已經 grep 確認 resource_library_app_service.py 這支 530 行的檔整檔找不到任何一個權限檢查關鍵字,而且裡面有 9 段手寫 SQL。結果證實這個預判是對的。


現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。

  • 問題 1(改範本適用控制項不查歸屬)=M11 第 5 條,✅ 已修(CM-2178,commit abf1cf8a4;CM-2040 60015232f,1.21.0 出貨)。
  • 問題 2(資料庫帳密寫死):✅ 已修(CM-2049,commit 5d4c14221)。
  • 問題 3(建資源庫無權限檢查)=M11 第 13 條,✅ 已修(CM-2179,commit 94275d72f,1.21.0 出貨)。
  • 問題 4(Word 匯出未跳脫)=M11 第 18 條,✅ 已修(CM-2055,commit f85a82edb,1.21.0 出貨)。
  • 問題 5、6(Google 密鑰、雲端權杖加密金鑰):✅ 已修(CM-2051,commit ccbb27148;原作廢卡 CM-1631)。
  • 問題 7、8(AI 金鑰、簽章金鑰):✅ 已修(CM-2048,commit e8e134a4e;原作廢卡 CM-1607)。
§3

找到什麼:8 個問題

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡
1 🔴 高 改範本的「適用控制項」時,系統沒有檢查這份範本是不是你家的 別家公司的合規範本、或原廠給所有人用的公版範本,控制項清單被改掉,底下所有「我們怎麼做到這條」的記錄被整批刪除(連帶說明文字一起)。被害方不會收到任何通知 ① 是自己公司的管理員(這是一般客戶就有的角色)
② 系統裡存在一份「看得到但不屬於你」的範本——原廠公版(每家都看得到)或母公司分享下來的
app/oscal/service/resource_library_app_service.py:412
2 🔴 高 一組正在用的資料庫帳密被寫死在受版控的腳本裡(主機、埠號、資料庫名、帳號、密碼五樣齊全) 讀得到程式碼、又連得到內網的人可以直接連進 POC 資料庫。本專案規定 POC 等同正式環境(對外 demo、客戶會試玩)。同一組密碼字串散在 249 個受版控檔案裡,其中不少是配更高權限的 cmmgr 帳號 ① 讀得到程式碼倉庫
② 連得到 192.168.50.189(內網或 VPN)
docs/system-design/scripts/generate_db_schema_docx.py:34
3 🟡 中 建立資源庫的入口完全沒有權限檢查,只驗有沒有登入 連唯讀帳號都能建資源庫。而且每建一次就複製一整套框架目錄、每個查核項目各產一張流程範本——重複呼叫就是用很小的代價讓資料庫暴增 任何登入帳號 api/oscal/routes/resource_library_route.py:50
4 🟡 中 匯出 Word 時,使用者填的文字沒有做「跳脫處理」就塞進文件 有權編輯計畫內容的人,可以讓匯出的 Word 檔夾帶偽造的段落,或藏一段會叫閱讀者的 Word 去抓外部內容的指令。匯出檔就是要交出去的稽核證據,被動手腳等於證據本身不可信 ① 能編輯該計畫的文字欄位(專案負責人,或有範本編輯權的人)
② 另一個人去匯出並打開那份檔案
app/oscal/service/export/ssp_docx_generator.py:60
5 🟡 中
⚠️ 範圍外
Google 雲端硬碟的「應用程式密鑰」寫在 12 個受版控檔案裡 攻擊者可以冒充我們的系統向 Google 要權限,讀取客戶存在雲端硬碟的檔案。這把不是每套安裝各自產生的,要到 Google 後台重設 讀得到程式碼倉庫 + 誘使使用者走一次授權流程 docs/conversation-history/2026-04-21-.../part-05-of-14.md:8157
6 🟡 中
⚠️ 範圍外
保護客戶雲端硬碟權杖的加密金鑰寫在 10 個受版控檔案裡 配合問題 2 拿到資料庫之後,可以把所有客戶存著的雲端硬碟權杖全部解密,不需要再通過任何驗證 讀得到程式碼倉庫 + 要先透過問題 2 之類的路徑拿到資料庫 同上檔案 :8161
7 🟡 中
⚠️ 範圍外
OpenAI/Anthropic/Google 三把 AI 金鑰寫在 9 個受版控檔案裡 只要有一把漏掉沒作廢,就能拿公司帳單去用;OpenAI 那把還能讀服務端保存的請求紀錄(裡面常含客戶資料) 讀得到程式碼倉庫 + 該金鑰還沒被作廢 docs/conversation-history/2026-04-28-to-04-30-.../part-verbatim-01-of-03.md:3651
8 🟡 中
⚠️ 範圍外
登入憑證的簽章金鑰寫在 30 個受版控檔案裡 理論上可以偽造任何人的登入身分(包含最高管理員),不需要密碼。但安裝程式出來的每套系統都會各自產生一把新的,外流的是手動設定的開發/測試機那把 讀得到程式碼倉庫 + 目標機器還在用這把(主要是舊的手動設定機) docs/conversation-history/2026-04-21-.../part-03-of-14.md:14232

一到四號是這棒範圍內的。五到八號是掃描工具的「祕密金鑰搜查」順手在整個 repo 撿到的——它們是真的(每一條的檔案數都實際 git grep 數過),但那些目錄不是這次的掃描目標,不能因為只找到這四條就認為 docs/ 乾淨。這四條與 FR-081 的 I2 報告、FR-113 的 O1 報告高度重疊,是同一批舊帳。


§4

詳細說明

問題 1:改別人家的範本(唯一的新高風險)

白話:合規範本就是「這套框架裡,哪些控制項我們公司要做」的清單,底下還掛著每一條的實作說明。範本有三種身分:

  • 自己家的(TENANT)——你建的,你自己用
  • 母公司分享下來的(SHARED)——集團母公司做好給子公司參考的,子公司看得到但不該改
  • 原廠公版(SYSTEM)——我們出廠附的,所有客戶都看得到,誰都不該改

編輯範本的功能長這樣:

PUT /module-frame/<哪一份範本>     body: { "include_controls": [...] }

送出後系統會做三件事:更新這份範本的控制項清單、把被拿掉的控制項底下的實作記錄整批刪除、替新加的控制項建流程範本。

問題在於:這段程式從頭到尾沒有問過一句「這份範本是不是你家的」。

為什麼資料庫沒擋下來

系統其實有兩層防護,這次兩層同時失效,而且失效的原因彼此獨立:

第一層(資料庫層):compliance.module_frames 這張表有設「不准改別人家的、不准改公版」的規則。但這次的請求只送了控制項清單、沒有改到這張表上的任何欄位,所以程式根本沒有對這張表發出任何更新指令——沒發指令,規則就沒有機會觸發。而真正被改被刪的那三張表(oscal.profile_imports、oscal.ssp_implemented_requirements、oscal.catalog_control_parts)根本沒有設這層防護。

這點是實際數過的,不是推論:

# 整個 oscal schema 只有 5 張表有這層防護,這三張都不在裡面
$ grep -c "ALTER TABLE oscal.profile_imports ENABLE ROW LEVEL SECURITY" scripts/init/02-schema.sql
0
$ grep -c "ALTER TABLE oscal.ssp_implemented_requirements ENABLE ROW LEVEL SECURITY" scripts/init/02-schema.sql
0
$ grep -c "ALTER TABLE oscal.catalog_control_parts ENABLE ROW LEVEL SECURITY" scripts/init/02-schema.sql
0

第二層(程式層):系統裡已經有一支專門擋這件事的警衛程式——common/authz/sharing.py 的 assert_scope_writable。它的說明文字甚至直接寫明了為什麼不能只靠資料庫:

「RLS 的 update policy 判準是『資料租戶在我的 allowed path 內』,而母租戶的 allowed path 涵蓋整個子樹⋯⋯所以這一刀要顯式切在『作用租戶=資源租戶』上。」

問題是它只有在「使用者要改範本的分享身分」時才會被叫到:

# app/module_frame/service/module_frame_service.py:110-118
new_scope = payload.get("scope")
if new_scope is not None and new_scope != mf.scope:
    assert_scope_writable(mf.tenant_id, new_scope)   # ← 警衛只站在這個 if 裡面
    mf.scope = new_scope
self.module_frame_domain_service.update_module_frame(mf, locale)
# 適用控制項增減 → profile + IR 同步
if payload.get("include_controls") is not None and ...:
    self._resource_library_app_service.update_applicable_controls(   # ← 這條路沒有警衛
        uid, payload["include_controls"], user)

只送控制項清單、不送分享身分,就完全繞過警衛。

攻擊怎麼進行

  1. 攻擊者是自己公司的管理員——這是一般客戶就有的角色(scripts/init/04-seed-core.sql 預設就配了範本編輯權)。
  2. 他打開資源庫列表,看到原廠公版範本(每家公司都看得到,這是設計如此)或母公司分享下來的範本,抄下編號。
  3. 他送出 PUT /module-frame/<別人的範本編號>,內容只放 {"include_controls": []}。
  4. 網址入口的權限檢查問的是「你有沒有編輯範本的權限」——他在自己公司有,通過。
  5. 資料庫那層因為沒有欄位變動,連觸發的機會都沒有。
  6. 那份範本的控制項清單被清空,底下所有實作記錄被刪除(說明文字因為資料庫的連鎖刪除設定一起消失)。

打原廠公版的話,影響的是所有客戶。

怎麼修

在 update_applicable_controls 動手寫任何東西之前,先把範本那筆重新撈出來,明確擋兩件事:

  • 身分是原廠公版(SYSTEM)→ 直接拒絕
  • 範本的所屬公司跟呼叫者的公司不同 → 直接拒絕

做法照抄 common/authz/sharing.py 既有的判斷方式即可。不要想著靠資料庫補——那三張表沒有防護,而且它們本來就設計成「跟著範本走」,補防護是第二道保險、不是這條的解法。

同一條路徑上的 _init_ao_workflows(處理新增控制項那半邊)需要同一道檢查。


問題 2:資料庫帳密寫死在腳本裡

白話:一支產生資料庫結構文件的腳本,把連資料庫需要的五樣東西全部打在程式碼裡:

# 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=<密碼字面值>)

主機、埠號、資料庫名、帳號、密碼——複製貼上就能連。 指向的是 POC 機器,依專案規定「等同正式環境、對外 demo、客戶會試玩」。

更麻煩的是同一組密碼字串的散布範圍——實際數過:

$ git grep -l '<見 .env>' | wc -l
249

249 個受版控檔案,其中不少配的是 cmmgr 這個會繞過「每家公司只看自己資料」隔離機制的帳號。

這條與 FR-081 的 I2 報告第 1 條、FR-113 的 O1 報告第 4 條是同一件事,不是新發現,但這次是從另一條路徑再次撞到——代表它還沒被處理。

怎麼修:腳本改成跟主程式一樣從環境變數讀(config/config_loader.py 已經有現成的 DB_HOST/DB_USER/DB_PASSWORD),把 188/189 上 cm_app 與 cmmgr 的密碼換掉,然後清掉那 249 個檔裡的字串。注意:單純在後面的 commit 把檔案刪掉沒用——舊的 commit 裡還讀得到。


問題 3:建資源庫不檢查權限

白話:建立資源庫的入口只驗「有沒有登入」,沒有問「你有沒有建的權限」。而隔壁那支做同樣事情的入口(ModuleFrameRoute.post)是有要求權限的。

這個檔案裡有一段註解解釋為什麼放開:建立時一定會綁呼叫者自己的公司、不會碰到原廠公版,所以「一般登入者即可」。

這個理由對了一半——就「會不會動到別人家的資料」而言確實成立。但它沒回答兩件事:

  1. 公司內部哪些角色該能建? 唯讀帳號、一般稽核員是不是也該能建範本庫?
  2. 每次呼叫的代價有多大? 建一次資源庫 = 複製一整套框架控制項目錄 + 每個查核項目各產一張 BPMN 流程範本。這不是一筆小寫入。而系統沒有裝任何流量限制,所以重複呼叫就是用很小的代價把資料庫灌大。

怎麼修:補上跟隔壁入口一樣的權限要求。Excel 匯入、Word 匯入那兩條也會建資源庫,三個入口要一致,不然補了這個還是有其他路可以進來。


問題 4:匯出的 Word 檔可以被動手腳

白話:Word 檔(.docx)內部其實是一種標記語言寫的文字檔。系統匯出時,會把使用者填的「系統名稱」「系統描述」「網路架構」「資料流」等欄位填進 Word 範本。

填進去的時候沒有做「跳脫處理」——也就是沒有把使用者輸入裡的特殊符號轉成安全形式。所以如果有人在系統描述裡填入一段看起來像 Word 內部指令的文字,它就會被 Word 當成真的指令執行,而不是顯示成文字。

技術上的位置:

# app/oscal/service/export/ssp_docx_generator.py:60
tpl.render(context)          # ← 這裡少了 autoescape=True

用的那個套件(docxtpl)預設是關閉跳脫處理的,要自己明確打開。

可以造成什麼:

  • 偽造段落——在匯出的稽核文件裡插入稽核員根本沒寫過的實作說明或標題
  • 藏外部指令——Word 有一種「欄位指令」可以叫閱讀者的 Word 去抓外部檔案。閱讀者一開啟文件就會觸發

為什麼這條重要:匯出的 SSP 就是要交出去給稽核方的正式文件。內容能被動手腳,等於證據本身不可信。

怎麼修:tpl.render(context, autoescape=True),一行。或者把每個從資料來的字串先包成套件提供的安全型別。在這個出口修比在每個輸入端擋好——因為 Word、PDF、ODT 三條路都走這同一個出口。


問題 5~8:舊憑證還躺在對話紀錄裡

這四條是掃描工具的祕密金鑰搜查在整個 repo 撿到的,不是這棒的掃描範圍,但既然撿到就照實報。每一條的檔案數都實際數過:

是什麼 幾個受版控檔案 現在的狀態
Google 雲端硬碟應用程式密鑰 12 沒在 CM-1607/CM-1608 的清單裡,未換過
客戶雲端權杖的加密金鑰(兩把,兩個環境) 10 沒在清單裡,未換過
OpenAI/Anthropic/Google AI 金鑰 9 repo 內部紀錄說 2026-09-08 已作廢,但檔案裡的字串沒清(CM-1607 尚未開工)
登入憑證簽章金鑰 30 安裝程式出的每套系統各自產生新的,外流的是手動設定的開發/測試機那把

這四條跟 FR-081 的 I2 報告第 4~6 條、FR-113 的 O1 報告第 3 條指的是同一批東西。 從三個不同方向重複撞到同一件事,本身就是個訊號:這批舊帳還在原地。

特別注意前兩條:它們不在現有的 CM-1607/CM-1608 修正清單裡。前兩棒的報告雖然提過雲端硬碟相關的金鑰,但既有工單列的是 AI 金鑰與資料庫密碼。如果照現有工單做完,這兩把還是會留著。

問題 6 和問題 2 可以串起來:拿問題 2 的資料庫密碼把客戶的雲端硬碟權杖撈出來,再用問題 6 的加密金鑰解開,就能直接讀客戶的雲端硬碟內容。兩條單看都是「中」,串起來的實際後果比任何一條單獨都嚴重。


§5

這棒沒有回答的事

派工卡片點名要看的幾件事裡,有兩件掃描沒有給出結論,要據實說明:

一、ssp_libreoffice_converter.py 的暫存檔與多人同時匯出。卡片特別點名這支是整批唯一會呼叫外部程式的檔,問了「檔名會不會被使用者控制」「多人同時匯出會不會互相踩到」。掃描讀了這支檔但沒有提出任何發現——可能是真的沒問題,也可能是這一輪的快速檔沒追到那個深度。不能當成「已確認安全」,要確認得另外開檔細讀。

二、9 段手寫 SQL 的 SQL injection 檢查。研究員從 update_applicable_controls 這一段找到了問題 1,但沒有逐段回報其餘 8 段的參數綁定狀況。從問題 1 的描述看,那幾段用的是綁定參數(:u、:ids、:sid)而不是字串拼接,這是安全的寫法;但這是從報告內容推得的,不是研究員逐段核過的結論。

三、module_frame_template_ssp_route.py(38 行,零守門)通到哪裡。掃描沒有針對這支提出發現。

如果這三件會影響後續決策,需要另外開一棒針對性地讀。


§6

執行概況

項目 內容
掃描範圍 11 支檔、1,772 行(資源庫 + 公版範本 + 匯出三條線)
程式碼版本 77a8906d(branch feature/review,工作區有未提交異動)
工具設定 claude-security plugin v0.10.2.3/effort low/focus attack-surface
派出/回報 26 個 agent 派出,26 個回報,0 失敗、0 空回(研究員 2 派 2 回,零重試)
候選發現 8 條原始候選,去重後仍 8 條,零漏投(unreviewed_candidate_sites: 0)
面板驗證 有跑完。8 條各由 3 位驗證員從不同角度投票,共 24 票;問題 1~7 全部 3:0 通過,問題 8(登入簽章金鑰)2:1 通過
驗證章 verified
耗時 3 小時 0 分
報告原檔 CLAUDE-SECURITY-20260921-060255/(工具產出,含 JSONL/SARIF/版本戳記)

⚠️ 沒有任何程式碼被執行過。 沒有跑測試、沒有實際發動攻擊、沒有驗證過概念驗證程式。所有結論都來自讀程式碼。

本報告所有 8 條發現,runner 都自己開檔核對過,不是只採信面板:問題 1 核了那段程式、警衛程式的說明、module_frame_service.py 的呼叫條件、三張表沒有防護(grep -c 各回 0)、以及入口的權限要求;問題 2 核了第 34 行字面值與 249 個檔的數字;問題 3 核了兩支入口的權限裝飾器對比;問題 4 核了第 60 行;問題 5~8 各以 git grep -l 數過檔案數。


§7

建議的後續

  1. 問題 1 應優先處理——這是本批目前唯一的新高風險,修法明確(既有警衛程式抄過來),影響面是「客戶資料可被別的客戶破壞」。
  2. 問題 5、6 要補進既有的憑證清理工單(CM-1607/CM-1608)——它們現在不在清單上,照現有工單做完還是會留著。問題 6 與問題 2 串起來可以直接讀到客戶的雲端硬碟內容。
  3. 問題 3、4 可以併一張卡——都是單點修改(補一個權限裝飾器、加一個參數),風險中等但修起來很便宜。
  4. 考慮補一棒:上面「這棒沒有回答的事」那三項,特別是 LibreOffice 那支(整批唯一呼叫外部程式的地方)。
  5. 問題 2、7、8 交給首腦判斷——與前兩棒重疊,應該併進既有項次而不是另計。