卡片:CM-2007 | 只掃不修,修正卡由首腦統一開 報告日期:2026-09-21
派工時最擔心的那個數字缺口,查下去不是缺口——「5 支入口只守了 3 支」是真的,但沒守的那兩支是讀取,而它們各自有租戶比對擋著,設計是對的。 這一棒範圍內沒有權限漏洞,找到的是一條中風險:解析失敗時,系統把程式當掉的原始訊息原封不動存起來、再原封不動回給前端,裡面會夾帶伺服器的檔案路徑與套件內部結構。另外工具在範圍外撿到四條舊憑證外洩,全部是前面棒次已經報過的同一批。
「合規框架 PDF 匯入」是把一份原始標準文件(例如 CMMC 的官方 PDF)丟進系統,讓系統自動讀出裡面有哪些控制項,確認無誤後正式收進產品的框架資料庫。因為框架是全平台共用的東西(每一家客戶看到的是同一份),所以這條線的設計是「只有平台管理員能寫」。
流程分兩階段:上傳 PDF 先解析成草稿(第一階段),人工檢視確認後才真的寫進框架庫(第二階段)。中間那張草稿就是「解析工作」。
這一棒檢查 6 支檔、1,343 行——誰能用這五個功能、上傳的 PDF 會怎麼被處理、別家公司看不看得到你的草稿。
現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。
- 問題 1(解析失敗回原始訊息)=M11 第 22 條,✅ 已修(CM-2183,commit
c33f7e828,1.21.0 出貨)。
| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| 1 | 🟡 中 | 解析失敗時,把程式當掉的原始訊息整段存起來並回給前端 | 前端畫面上會出現伺服器的檔案路徑、套件版本與內部結構。攻擊者不必猜系統長什麼樣子,餵一份壞 PDF 就能讓系統自己說出來 | 必須是平台管理員(能呼叫上傳的人本來就是最高權限那一小群) | framework_parse_job_service.py:220-224(存)framework_parse_job_service.py:667(回傳) |
| — | ⚠️ 範圍外 | 工具的密鑰專項掃到四條舊憑證外洩(AI 金鑰與雲端硬碟金鑰、資料庫密碼、登入簽章金鑰、原廠管理員密碼) | 見前面棒次 | — | 全部是 O1/O2/O5 已報過的同一批,不另計 |
範圍內只有這一條。 下面把派工時點名要追的五條線索逐條交代,包含查證後不成立的三條——「沒寫到」不等於「沒看」。
這是什麼問題(白話)
上傳 PDF 之後系統會去解析。如果解析過程中程式出錯了,系統的作法是把程式當掉時吐出來的那段原始訊息,整句存進資料庫,之後前端來查這張解析工作的狀態時,再把那句原封不動送回去顯示在畫面上。
程式當掉的訊息不是寫給使用者看的,它是寫給工程師除錯用的,內容通常包含伺服器上的完整檔案路徑、用到的第三方套件與版本、以及內部資料長什麼樣子。
出事會怎樣
攻擊者不需要猜這台伺服器裝在哪個目錄、用的是哪一版套件——故意餵一份壞掉的 PDF,讓系統自己把這些說出來。這些資訊單獨不會直接造成損害,但它是後續攻擊的地圖:知道套件版本就能去查那一版有沒有公開的已知漏洞,知道路徑就能在別的漏洞裡填對位置。
要先有什麼才打得到
必須先是平台管理員。上傳那支功能第一行就擋了(require_platform_admin()),一般客戶的管理員打不到。這也是它只算中風險而不是高風險的原因——能打到這裡的人本來就是系統裡權限最高的那一小群。
但仍然值得修,理由有兩個:①平台管理員的帳號也可能被盜,這時候這條路會變成偵察的第一步;②錯誤訊息回前端這件事本身違反「對外只給業務語意的錯誤」的通則,別的地方照抄會長出更嚴重的版本。
在哪裡
存進去的那一行:
# app/oscal/service/framework_parse_job_service.py:218-224
except Exception as e:
logger.exception(...)
error_code = FlowControlErrorCode.GRC_FRAMEWORK_PARSE_FAILED.value[1]
error_message = FlowControlErrorCode.GRC_FRAMEWORK_PARSE_FAILED.value[0]
return self._domain.write_error(
job.uid, error_code, f"{error_message}: {e}", curr_user
)
# ^^^^ e 是原始例外,整句被接在業務訊息後面送回前端的那一行:
# app/oscal/service/framework_parse_job_service.py:667(_to_detail_dict 內)
"error_message": job.error_message,建議怎麼修
業務訊息與技術細節分開:資料庫與回應只留 error_code 加上固定的業務說明(「解析失敗,請確認檔案格式」),原始例外只進 logger.exception——那一行本來就已經寫了(:219),技術細節在 log 裡查得到,不需要也經過前端。
派工卡列了五條要追的線索。兩條證實有事、三條查證後不成立。 三條不成立的也寫出來,因為「設計本來就是對的」跟「沒人去看」是兩回事。
開檔核對:五支入口確認是 list_active(列草稿)、parse(上傳解析)、get_detail(看草稿內容)、discard(丟掉草稿)、confirm(確認寫進框架庫)。三次 require_platform_admin() 分別在 parse:143、confirm:259、discard:366。
差的兩支是 list_active 與 get_detail,都是讀取。 而這正是這條線刻意的設計——程式註解寫得很清楚:「合規框架為全域資源:匯入僅 root 租戶」,也就是寫入只給平台管理員、讀取放給大家。
關鍵是:讀取放開了,租戶隔離有沒有跟著放開? 逐支核對:
list_active:114:查詢帶 tenant_id=self._effective_tenant_id(ctx),只撈得到自己租戶的草稿。get_detail:240:if job.tenant_id != self._effective_tenant_id(ctx): raise NotFound(...)——拿到別人的編號回 404,而且是 404 不是 403(不洩漏「這個編號存在」)。所以這兩支不是「沒守」,是「守的東西不一樣」:不守「你是不是管理員」,但守「這張草稿是不是你們家的」。對讀取而言這是正確的守法。
連帶查證的三件事(都是缺口的必要條件,全部不成立):
framework_parse_job_domain_service.py:38 是 uuid.uuid4(),隨機碼不是流水號。infra/oscal/model/framework_parse_job.py:12 繼承 TenantScopedMixinModel,tenant_id 是資料表欄位,而且在 repo 的 _PROTECTED_FIELDS 名單裡(framework_parse_job_repo_impl.py:16),更新時改不動。list_by_tenant:45-48 明確 filter(FrameworkParseJob.tenant_id == tenant_id)。派工卡問的是「進度、錯誤訊息會不會洩漏檔名或內容」。查下來:進度(status)只有固定幾個字串不洩漏;錯誤訊息會,而且洩漏的比檔名更多。這是本棒唯一的範圍內發現。
開檔核對:全域有請求體積上限,core/app_factory.py:104 把 MAX_CONTENT_LENGTH 設成 MAX_REQUEST_BODY_MB(預設 50MB,config/config.py:105),而且 app_factory.py:427-431 還多做一道——在 before_request 階段先看 Content-Length 提前擋掉,不等到讀完 body 才報錯。
解析的頁數與巢狀上限不在本棒範圍(那在 jedi-oscal-v2 套件的 CMMC 轉接器裡,是 O7b 那一棒的事),本棒只確認了「進得來的檔案有大小天花板」。
解析失敗的殘檔:PDF 會先存進系統儲存拿到一個編號(parse:198-209),解析失敗時這個檔案不會被刪。但這不算缺口——它是刻意的:失敗的草稿前端還要顯示 PDF 預覽讓人看哪裡出錯,而且有 TTL 清理(下面線索④)。
見線索①的連帶查證。uuid.uuid4(),猜不到也偷不到。
這條要更正派工卡的前提。 派工卡說「解析是背景執行的」,開檔核對的結果是:不是。parse() 從頭到尾在同一個請求裡跑完——adapter.pdf_parser(BytesIO(raw_bytes)) 在 :214 直接同步呼叫,解析完才回應。所以「背景工作沒有身分脈絡、讀租戶設定時亂挑一個」那個已知前例在這裡不適用,身分從頭到尾是呼叫者本人。
唯一真正在背景跑的是每天清草稿的排程(core/scheduler.py:235-255,每天 UTC 01:00 軟刪七天前的草稿)。而這支恰恰是正面教材——它明確用 system_context("framework_parse_job_cleanup") 宣告自己的身分,程式上方的註解還特地寫明為什麼必須這樣寫:
要掃全部租戶,故以
system_context()顯式宣告繞 RLS——省略就會靜默掃到 0 筆而不報錯(無身分時session_scope是 fail-closed,什麼都看不到)。
這正是前例那個坑的正確解法,而且作者知道自己在解什麼。
派工卡沒點名,但沿著資料流看到就一併核對了:
parser_type 是使用者填的字串,直接餵進工廠函式——這種形狀通常有事。但追進套件(jedi_oscal_v2/infra/adapter/oscal_parser_factory.py:31-34)是查表,查不到就 400,不是動態載入模組,沒有注入空間。_guard_version_replaceable:386-395)——parse 跟 confirm 各檢查一次,而且註解寫明了為什麼要檢查兩次:避免兩次操作中間有人改了狀態。這跟前面幾棒「守了一半」的病正好相反,是本棒最紮實的一段。confirm 的前置條件——:268 檢查 status != "awaiting_review" or not job.is_active 回 412,重複確認進不來。前面六棒的高風險全部是同一個病:有人守了一半——同一支方法這條路有檢查、那條路沒有;隔壁有守、這支沒守。派工時要我帶著這個模式來追,而這一棒追下來的結論是:這條線沒有這個病。
具體是三件事讓它躲掉了:
require_platform_admin() 都在方法第一行,不藏在 if 裡面(O2 那條高風險就是警衛站在 if 裡面)。_effective_tenant_id(ctx),而那支函式的 docstring 明確解釋了為什麼建立與檢視要用同一套算法(:86-93)。同一件事只有一份實作,就不會有兩份不一致。但這不代表可以放鬆。 這條線躲掉的原因之一是它入口少(5 支)、權限模型單純(只有平台管理員/非平台管理員兩種)。O9 那棒的接線總裝有 18 支檔,複雜度高一個量級,同樣的模式未必還成立。
| 項目 | 內容 |
|---|---|
| 掃描範圍 | 6 支檔、1,343 行(合規框架 PDF 解析工作) |
| 掃到的 commit | dc6aef6560512dcdc9d8c95996a167d824d707bf |
| 工具設定 | effort low、focus attack-surface |
| 派出幾個 agent/回報幾個 | 14 派 14 回,零失敗零重試(其中研究員 2 派 2 回) |
| 三人面板有沒有跑完 | ✅ 跑完,章是 verified |
| 面板數字 | 4 個候選、12 票全投、零漏投、零遺失,全部 3:0 通過;一條被面板降級(見下) |
| 面板降級 | F3(登入簽章金鑰外洩)研究員報 HIGH,三位審查員一致投 MEDIUM,工具已自動降級 |
| 耗時 | 2 小時 16 分 |
| 工具正式報告落點 | CLAUDE-SECURITY-20260921-154612/(該目錄有自己的 .gitignore,不進版控) |
⚠️ 這份報告的可信度要分兩層看:
第一層(工具的四條,可信度高):三人面板完整跑完、12 票全投、驗證章是 verified。但這四條全部是範圍外——它們來自密鑰專項掃描(掃全 repo 的固定動作),不是這 6 支檔裡的問題,而且四條都是前面棒次已經報過的同一批憑證。
第二層(本棒範圍內的,靠 runner 自己開檔):工具在這 6 支檔裡一條都沒找到。 上面「問題 1」與五條線索的全部查證,是 runner 依派工卡逐條開檔核對出來的——每一條都附了檔名與行號,寫的是打開那幾行看到的事實,不是推論。沒有經過三人面板,但也不需要:「這一行寫的是什麼」不是需要投票的事。
工具在範圍內零發現這件事本身要怎麼解讀:不等於這 6 支檔絕對乾淨,只能說在快速檔(low)的一輪裡,自動研究員沒有在這裡找到值得報的東西。派工卡預判的「數字缺口」經查是設計而非漏洞,這個判斷是 runner 開檔做的,與工具無關。