B4 掃描報告 — SSP 匯出 Excel 範本(CM-2076)

範圍:api/module_frame/routes/ssp_import_template_route.py(169 行)+ app/module_frame/service/ssp_import_template_app_service.py(1,500 行),共 2 檔 1,669 行。 掃描工具:Claude Code 官方 claude-security plugin,effort low,focus 生產程式碼。 掃描基準 commit:798a9b5f94a2(工作區有其他 session 的未提交改動)。 驗證章:verified。只掃不修。


1. 一句話結論

已知的那個洞確認成立,而且比原本記的更嚴重——它不只跨專案,是跨客戶;旁邊那兩支同檔方法查過了,一支安全、一支有前提成立才會出事的低風險缺口;另外掃到一條與本功能無關但更急的問題:一組還在用的資料庫密碼被寫進版控,而且跟著文件站公開在網路上。


2. 這一棒在檢查什麼

系統裡有個功能叫「下載 SSP Excel 範本」。SSP 是「系統安全計畫」,一份文件裡寫著一家客戶的系統長什麼樣、誰負責哪一塊、有哪些設備、每條資安控制項他們實際怎麼做。顧問要離線作業時,可以把整份計畫匯出成一個九頁的 Excel 檔帶走。

這一棒要確認的是:這個「帶走整份文件」的按鈕,有沒有檢查你是不是這份文件的主人。

同一支程式檔裡有三個入口:一個從「合規範本」匯出、一個從「某份 SSP」匯出、一個從「框架版本」匯出空白表。盤點時只查過其中一個,另外兩個沒人看過——這棒就是把三個一次看完。


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

  • B4-1(下載別人的 SSP)=M11 第 1 條,✅ 已修(CM-2188,commit 82618a297,1.21.0 出貨)。
  • B4-2(資料庫密碼進版控並公開):✅ 已修(CM-2049,commit 5d4c14221;密碼換發排在正式環境上版前統一做)。
  • B4-3(Excel 公式)=M11 第 20、21 條,✅ 已修(CM-2189,commit 895db0ef8,1.21.0 出貨)。

3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 嚴重度
B4-1 下載 SSP 時沒有檢查這份計畫是不是他自己的 任何登入者只要拿到別人的計畫編號,就能把別家客戶整份系統安全計畫下載走:人名、email、電話、地址、設備 IP 與 MAC、每條控制項怎麼做 有帳號且角色帶「資源庫可讀」權限(預設管理員就有);知道對方的計畫編號(其他 API 會回傳) app/module_frame/service/ssp_import_template_app_service.py:280 高
B4-2 一組還在用的資料庫密碼被寫進版控,而且跟著公開文件站發佈到網路上 任何看得到 repo 或那個公開文件站的人,都能直接連上 POC(等同正式環境)的資料庫,而且用的是會繞過客戶隔離機制的管理員帳號,讀寫所有客戶的資料 連得到那台資料庫的網路(內網/VPN/已進到內網的任何機器) docs/system-design/scripts/generate_db_schema_docx.py:34(同一組字串另散在約 250 個版控檔中) 高
B4-3 使用者填的文字原封不動寫進 Excel 格子,而檔案被設定成「打開就重算」 甲客戶在計畫裡填一段特製文字,乙顧問下載這份 Excel 打開時,那段文字會被 Excel 當成公式執行,把隔壁格的資料送去攻擊者的網站 攻擊者要能寫入該份計畫的欄位;要有第二個人下載並打開這個檔;部分手法還要對方點掉 Excel 的信任提示 app/module_frame/excel_template/generator.py:578 中

另外兩支被點名要查的方法,查完的結論在第 5 節(一支沒問題、一支有條件性缺口)。


4. 每條發現的詳述

B4-1 下載別人的系統安全計畫,不會被擋

白話說明

網址是 GET /ssp/<計畫編號>/excel-template。程式收到這個編號之後,直接拿去資料庫把那份計畫撈出來,然後產生 Excel 回傳。中間沒有任何一行程式去問「這份計畫是不是這個人的」。

入口那層是有兩道檢查的:一是「有沒有登入」,二是「這個人的角色有沒有『資源庫可讀』這個權限」。但第二道問的是**「你能不能讀資源庫」,不是「這份計畫是不是你的」——這是兩件不同的事。本專案的正規做法是把「是不是你的」這種檢查寫在下一層的商業邏輯程式裡,但這支下一層也沒寫**。

為什麼比原本登記的更嚴重

原本總表第 128 項記的是「跨專案」。實際查過資料庫結構後,範圍更大:

  • 存放 SSP 的那批資料表(oscal.system_security_plans 等)沒有客戶編號欄位,也沒有開資料庫層的客戶隔離(RLS,就是「每個客戶只能看自己資料」的資料庫機制)。我實際核對過 scripts/init/02-schema.sql:oscal 這個區域底下只有五張「匯入工作紀錄表」有客戶欄位與隔離設定,SSP 本體與它的子表都沒有。
  • 也就是說,程式這一關沒擋,資料庫那一關也擋不住。這不是「跨專案看得到」,是跨客戶看得到。

在哪裡

位置 是什麼
api/module_frame/__init__.py:231 網址登記處
api/module_frame/routes/ssp_import_template_route.py:97-116 入口,只掛了「要登入」+「資源庫可讀」
app/module_frame/service/ssp_import_template_app_service.py:280 generate_for_ssp 直接用傳進來的編號撈資料,全檔沒有任何歸屬檢查
di_containers/module_frame/module_frame_containers.py:231 這支服務的組裝處——連權限檢查器都沒有被裝進來,所以想呼叫也沒得呼叫

怎麼修

同專案已經有做對的範例可以照抄:

  • app/oscal/service/export/ssp_export_app_service.py:74-75
  • app/oscal/service/ssp_control_impl_import_service.py:97

兩支都是在方法的第一行就先確認「呼叫的人是不是這份計畫所屬專案的參與者」。做法:把 SspPermissionChecker 加進 SspImportTemplateAppService 的建構參數、在 di_containers/module_frame/module_frame_containers.py:231 補上組裝,然後在 generate_for_ssp 的第一行呼叫參與者檢查,不通過就擋下來。

另外建議(不屬本棒範圍,但這條的影響靠它才收得乾淨):給 oscal 底下的 SSP 相關資料表補上客戶編號與隔離設定。現在的狀況是「程式少寫一行檢查,整個客戶隔離就沒了」,沒有第二道防線。

首腦核對註記:三個獨立檢查員分別從「打不打得到」「出事多嚴重」「有沒有其他機制擋得住」三個角度投票,三票全部認定成立。


B4-2 資料庫密碼被寫進版控,並公開在文件站上

白話說明

docs/system-design/scripts/generate_db_schema_docx.py 這支腳本裡,資料庫的主機、埠號、庫名、帳號、密碼全部是直接寫死的文字。這支檔案在版控裡。

更麻煩的是,同一組密碼字串還出現在約 250 個版控檔案中,其中有 67 個是 docs/features/ 底下已經轉成 HTML 的文件——而那批 HTML 是推上 main 就自動發佈到公開網站的(https://guidantai-feature-doc.jedicotech.com/,不需要登入就能看)。

檢查員實際比對過:這組密碼不是過期的舊值,跟現在 .env 和 docker/production/guidant.env 裡在用的是同一組,沒有輪換紀錄。而且依專案自己的文件,這組密碼 cm_app(受客戶隔離限制)跟 cmmgr(系統管理員,設計上就會繞過客戶隔離)共用。

這件事已經違反本專案 CLAUDE.md 第一條「禁止將憑證寫入任何版控檔案」。

出事會怎樣

任何人只要連得到那台資料庫(內網、VPN,或任何已經進到內網的機器),就能用管理員帳號連上 POC 環境——而 POC 依專案定義等同正式環境,是給客戶試玩、對外 demo 用的。連上之後,所有客戶的專案、計畫、使用者、稽核證據都可讀可改,客戶之間的隔離完全失效。

在哪裡

  • docs/system-design/scripts/generate_db_schema_docx.py:34(密碼寫死處;實際使用在同檔 :1514)
  • 同一字串散在約 250 個版控檔,含 docs/features/ 下 67 個已發佈的 HTML

檢查員也確認過沒有任何機制擋著:.gitignore 只蓋了 .env 跟 .secrets/,蓋不到這些路徑;沒有 git 過濾器、沒有 hook、CI 裡也沒有掃密碼的步驟。

怎麼修

  1. 先輪換密碼,不是先刪檔。DEV、基線庫、STG、POC 的 cm_app 與 cmmgr 都要換——這組值已經進了 git 歷史、也已經發佈在公開頁面上,刪掉那一行不會讓它失效。
  2. 腳本改成從環境變數或 .env 讀(os.environ["PGPASSWORD"])。
  3. 在下次推 main 之前,把這組字串從版控文件與已產生的 HTML 裡清乾淨。
  4. 加一道 commit 前的密碼掃描,讓「憑證不進版控」這條規則由機器執行,不要只靠人記得。

首腦核對註記:三票全數認定成立。這條不在本棒原定範圍內(是順著本功能的設定檔追出去的),但嚴重度與急迫性都高於本棒主題,故照實列入。


B4-3 使用者填的字會被 Excel 當成公式跑起來

白話說明

產生 Excel 的時候,程式把使用者填過的文字——聯絡人姓名地址、元件說明、設備欄位、每條控制項的做法敘述、文件檔名——原封不動塞進儲存格。Excel 有個規則:任何以 = 開頭的內容會被當成公式。而這個檔案在產生時被設定成「打開時全部重算」(fullCalcOnLoad = True)。

所以甲客戶在自己的計畫裡填一段以 = 開頭的特製文字,乙顧問下載這份 Excel、打開,那段文字就會被 Excel 執行。

出事會怎樣

攻擊者可以讓公式把隔壁儲存格的內容(也就是這份計畫裡的其他資料)串進一個網址,送到他自己的伺服器;某些寫法在使用者點掉 Excel 的信任提示後,還能在對方電腦上執行外部指令。注意這個執行發生在對方的電腦上,不在我們的系統裡,我們的防護管不到。

為什麼是中而不是高

要三件事同時成立:攻擊者要先有權限寫入那些欄位、要有第二個人下載這份檔案、部分手法還要對方點掉 Excel 的信任提示。不像 B4-1 那樣一個網址就打得到。

在哪裡

  • 寫進格子的地方:app/module_frame/excel_template/generator.py:578(_write_filled_rows)
  • 上游只檢查長度、沒過濾內容:api/oscal/serializers/ssp/ssp_control_implementation.py:42、api/module_frame/serializers/module_frame_party.py:9,16

怎麼修

在寫入的那幾個共用函式處理,不要在每個呼叫端各修一次(否則下次新增欄位又會漏)。做法:值是字串且開頭是 =、+、-、@、Tab 或換行時,前面加一個單引號,或寫入後強制把儲存格型別設成文字。要改的三處:_write_filled_rows、_build_vertical_single_row_sheet,以及 lookup 工作表的產生函式——這三支都吃資料庫來的文字。

首腦核對註記:三票全數認定成立。這條正好落在 B4 與 B6 的接縫上——問題的源頭是 B4 把使用者填的內容當成可信任的資料傳下去,真正寫進檔案的動作在 B6 的 generator.py。B6 那一棒會從另一側再確認一次。


5. 被點名要查的另外兩支方法(首腦交辦)

盤點時只看過 generate_for_ssp,另外兩支沒人讀到底。兩支都實際開檔讀過了:

generate(範本版,行 222-253)— 沒有同類問題

網址 GET /module-frame/<編號>/ssp-import-template,拿範本編號去撈合規範本。

查法與結論:compliance.module_frames 這張表有客戶編號欄位,而且有開資料庫層的客戶隔離(scripts/init/02-schema.sql:24303 起,四種操作各有一條規則)。查詢時資料庫會自動只回傳這個人所屬客戶的資料,別家客戶的範本編號撈出來就是空的,程式會直接回「找不到」。

所以這支雖然一樣沒有在程式裡寫歸屬檢查,但資料庫那一關擋得住。掃描的三個檢查員也沒有對這支提出任何發現。

⚠️ 但要記一筆:這支是靠資料庫隔離在守,不是靠程式。同客戶底下不同專案之間的細分歸屬,這層擋不到;若未來 SSP 那批表補上隔離、或範本表的隔離設定被改動,這支的安全性會跟著變。

generate_by_framework_version(框架版本版,行 599-651)— 摸得到客戶資料,但有限

首腦問的是:這支到底是「只查全域框架目錄」(風險低),還是「也會碰到客戶資料」(風險高)。答案是後者,但摸到的範圍有限。

拆成兩半看:

控制項清單那一半:純全域,沒問題。 framework_version_uid → oscal.framework_versions → catalog_id → 該框架的全部控制項。oscal.frameworks 與 oscal.framework_versions 兩張表都沒有客戶欄位、也沒有隔離設定——因為它們本來就是全域共用的產品資料(CMMC、ISO 27001 這些框架,每家客戶看到的都一樣)。這部分是唯讀的公用目錄,沒有跨客戶問題。

下拉選單那一半:會撈到呼叫者所屬客戶的實際資料。 行 640 呼叫 _fetch_lookups_without_mf(),它會撈(行 676-689):

撈什麼 撈到的內容 對應程式
使用者 該客戶全部使用者的暱稱與帳號(email) :809
部門 該客戶全部部門名稱 :816
設備 該客戶全部設備的名稱與 IP :849
資訊系統 該客戶全部資訊系統的簡稱與名稱 :861
範本程序書 空的(這支刻意留空) :678

另外行 641 的 _fetch_lookups_helpers() 還會多帶使用者的 email 與姓名、設備的 IP 與作業系統。

這算不算漏洞?結論:不算跨客戶外洩,但是「權限粒度過寬」的缺口。

我核對過這四張表(public.users、public.org_units、public.devices、compliance.information_systems)都有開客戶隔離,所以撈到的一定是呼叫者自己公司的資料,拿不到別家客戶的。

但問題在於:這支的入口只要求「資源庫可讀」權限。一個只被授予「看合規範本」的低權限使用者,可以用這支下載一份空白範本,而這份空白範本的下拉選單裡會附上全公司的使用者 email、全部設備的名稱與 IP、全部資訊系統清單。這在滲透測試裡是很好用的內部偵察材料——雖然不跨客戶,但它讓一個本來只該看到範本的人,拿到了全公司的資產與人員清冊。

三個檢查員沒有把這條提為獨立發現(掃描重點在跨客戶那條),這是我實際讀程式後補的判斷,屬中低風險,是否要收成修正卡請首腦裁示。 若要修,方向是把下拉資料的取得改成依呼叫者實際權限縮減,而不是一律給全客戶範圍。


6. 這份結果可信到什麼程度

分兩層講,這兩件事不一樣:

「這三條存在嗎」——可信度高。 每一條都由三個角度不同的獨立檢查員各投一票,九票全數投出、沒有漏投,三條都是三票全數認定成立,沒有任何一條是靠少數票勉強過關的。而且三條的關鍵事實我都另外自己開檔核對過:B4-1 的資料表隔離設定、B4-2 的密碼是否還在用、B4-3 的寫入位置,都對得上。

「只有這三條嗎」——不保證。 理由:

  1. 這次用的是最快的掃描檔位(effort low),一輪研究員加一輪投票,沒有跑威脅建模與廣度掃描那些階段。
  2. 第 5 節那條 generate_by_framework_version 的權限過寬問題,是我讀程式讀出來的,掃描工具沒報——這直接說明工具會漏。
  3. 掃描過程沒有執行任何程式,全部是讀出來的判斷,沒有實際打過任何一個網址驗證。B4-1 建議實際打一次確認。

7. 執行概況(數字,給工程師看)

項目 數值
掃描範圍 2 檔 / 1,669 行
基準 commit 798a9b5f94a2(工作區 dirty,有平行 session 改動)
檔位 effort low,focus 生產程式碼
研究員 派 2 支,回 2 支
原始候選 5 條 → 去重後 3 條
投票 三個檢查員 × 3 條 = 9 票,全數投出
票型 F1 3:0、F2 3:0、F3 3:0(無任何一條被降級)
驗證章 verified
耗時 約 91 分鐘
工具產出原始報告 CLAUDE-SECURITY-20260922-090440/(未入版控)

8. 待首腦裁決(掃描當時;2026-10-01 同步:第 1、3 項對應的第 128、149 項已修,CM-2188,1.21.0 出貨;第 2 項密碼輪換屬環境異動,查不到處理紀錄故保留原文)

  1. B4-1 要不要立刻開修正卡(影響跨客戶,建議是)。
  2. B4-2 不屬本棒範圍但更急——密碼輪換是環境異動,依環境鐵律須由決策者發令,runner 不自行處理。
  3. 第 5 節的 generate_by_framework_version 權限過寬要不要收成獨立卡。
  4. B4-3 待 B6 從另一側確認後再一起決定修在哪一層。