C4b 掃描報告 — 稽核結果 Excel 匯入(CM-2104)

範圍:19 檔/1,585 行,跨兩個 repo。套件側 jedi-compliance-audit 18 檔/1,469 行,包括 Excel 匯入服務、Excel 解析器與註冊表、框架對照表、評估目標對齊、判定彙總、佐證配對、兩支與 C4a 共用的接縫檔,以及解析工作單的資料層六支(含工作流程與控制項的對照查詢);主專案側 1 檔/116 行(api/project/routes/ar_import_route.py)。 掃描工具:Claude Code 官方 claude-security plugin,effort low。兩側各跑一次,因為工具只認得它所在那個 repo 的檔案。 掃描基準:套件側 eafc7ae511d5(monorepo 主 checkout,feature/review);主專案側 c0673705026e(feature/review)。兩邊工作區都有其他 session 還沒 commit 的改動。本機 BE 載入的是修正分支 worktree 裡的套件;本棒三支核心檔(服務、解析器、對照查詢)我逐一比對過,內容與主 checkout 一致。 驗證章:兩次都是 verified。只掃不修,兩個 repo 的程式碼都沒改。


1. 一句話結論

權限檢查跟 C4a 一樣齊全;卡片最擔心的「佐證撈到別的輪次或別家客戶」,追下去三條都不成立。真正的問題還是出在「上傳的 Excel 怎麼被打開」:打開的方式比 Word 還危險,一份不到 5KB 的檔就能吃掉好幾百 MB 記憶體。

  • 工具報的兩個問題(兩側共 9 票全數投出、全部 3:0):
    • Excel 列數沒有上限(中,面板把握度「高」):解析器一路讀到「最後一列」,而最後一列在第幾列是上傳者自己決定的。本機實測:一份 4.9KB 的檔,只在第 20 萬列放一格,就耗時 10.9 秒、吃掉 355MB。Excel 最多到第 104 萬列,照比例算約 1.9GB、將近一分鐘。
    • 壓縮炸彈(套件側評中、主專案側評低):跟 C4a、總表第 124 項同一個病。套件側與主專案側各自掃到。
  • 我自己查到、工具沒報的兩條(未經投票):
    • 確認匯入時,前端送來的佐證資料照單全收(第 6.1 節):檔案編號、連結網址都原樣存進觀察紀錄,沒有核對「是不是這一輪的佐證」。之後有人點開那筆佐證時,連結會直接開、檔案會直接預覽。這不是新的洞,而是一條把總表第 34 項(任意檔案編號都能下載)變成「讓別人替你點開」的路,並且多了一個「用連結釣同事」的小口子。
    • 解析工作單表的資料庫隔離規則寫錯:跟 C4a-3 是同一條,這裡不重報,只補一句「Excel 那張也中」。

卡片點名要追的三件逐一查過,全部排除,見第 5 節。


2. 這一棒在檢查什麼

「稽核結果 Excel 匯入」讓稽核員把外部做好的稽核紀錄表(亞航 CMMC Level 1 格式)上傳進系統。系統會自動讀出每個控制項的判定(符合/不符合)、查核方法、引用的佐證,替每個控制項建觀察紀錄、下判定,不符合的再自動開一筆風險。

流程跟 C4a 一樣分四步:上傳+解析 → 預覽 → 捨棄 → 確認匯入。這一棒比 C4a 多了一件事:自動配對佐證。系統會拿 Excel 裡寫的佐證檔名,去比對這個控制項底下已經上傳過的檔案,自動勾選看起來相符的,讓稽核員不用一個個挑。

所以除了 C4a 那三個問題,這一棒還要回答:自動配對佐證時撈的範圍,有沒有可能撈到別的輪次、別的專案、別家客戶的檔案?


3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 嚴重度 誰掃到的 跟總表的關係
C4b-1 解析器讀到「最後一列」為止,最後一列由上傳者決定 幾 KB 的檔吃掉 GB 級記憶體、卡住一分鐘;幾份同時送,服務程序被砍,所有客戶一起斷線 某個專案的稽核員或管理者,而且那一輪要在「稽核中」 套件側 ar_report_parser/airasia_cmmc_l1_ar_v1.py:159-163(_parse_rows 起點與迴圈條件) 中 套件側(面板把握度高),本機實測 新,Excel 解析特有,C4a 沒有
C4b-2 上傳的 Excel 只量壓縮後大小,打開時整份載進記憶體(壓縮炸彈) 同上 同上 套件側 ar_report_parser/registry.py:16-17(打開方式),以及 import_adapter/registry_base.py:23-24(與 C4a 共用、該加檢查的地方) 中(套件側)/低(主專案側) 套件側、主專案側各自掃到 新入口,與第 124/127 項、C4a-1 同病同修法
C4b-3 確認匯入時,佐證資料照前端送來的存,沒核對歸屬 稽核員可以在觀察紀錄裡放任意檔案編號或任意連結;同專案的人點開時,檔案直接預覽、連結直接開 同上 套件側 ar_import_app_service.py:225(confirm_import 起點),以及 assessment_result_app_service.py:552(create_observation,一般新增觀察也走這裡) 低 runner 自行核對,未經投票 新;影響面依附在第 34 項上

4. 工具報的兩條(經三人面板投票)

4.1 C4b-1 一份不到 5KB 的 Excel,就能吃掉 GB 級記憶體

現況:已修(M12-3,FR-114 CM-2181,commit BE 48638cbdb/套件 2cb9d2cd,1.21.0 出貨)

場景

稽核員在稽核輪次頁按「匯入稽核紀錄」,選了一份自己做的 .xlsx。表面上它就是一張正常的亞航稽核表:工作表名稱是「檢查紀錄」,第 5 列有「索引號」「稽核結果」這些欄名,騙得過格式檢查。但他在第 1,048,576 列(Excel 的最後一列)的 A 欄隨便填了一個字。整份檔只有幾 KB,遠低於 10MB 上限。

系統收到檔案後,從第 6 列開始一列一列往下讀,每列讀 8 格,一直讀到「最後一列」。對系統來說,最後一列就是第 104 萬列。中間一百萬列全是空的,但每讀一格空格,openpyxl 這套程式庫都會在記憶體裡新建一個儲存格物件。算下來大約 840 萬個物件、將近 2GB 記憶體,還要花將近一分鐘。這段時間裡,資料庫交易一直開著。幾份同時送,服務程序被作業系統砍掉,所有客戶一起斷線。

本機實測(只在本機跑解析器那段迴圈,沒有打任何網址):

檔案大小 最後一列 耗時 記憶體高峰
4,914 bytes 200,000 10.9 秒 355 MB
(推算) 1,048,576 約 57 秒 約 1.9 GB

記憶體與耗時都跟列數成正比,所以推算是直接等比放大。服務逾時設定是 120 秒(main.py:267),一份就能卡住一個服務程序將近一分鐘。

為什麼會這樣

  • 套件側 airasia_cmmc_l1_ar_v1.py:162 取 max_row = ws.max_row,:163 開始 while r <= max_row,:164 每列呼叫 8 次 ws.cell(...)。openpyxl 的 max_row 是「檔案裡任何一格出現過的最大列號」,上傳者要多大就有多大。ws.cell() 讀到空格時會新建並保存一個物件。
  • 讀檔用的是一般模式(registry.py:17 的 load_workbook(file_path, data_only=True)),不是逐列串流的唯讀模式。
  • 空白列只會被跳過(:165-167),不會讓迴圈提早結束;也沒有「最多讀幾列」的上限。

怎麼修

改用唯讀模式打開,用 ws.iter_rows(min_row=6, max_col=8, values_only=True) 逐列讀。這個寫法遇到空格不會建物件。再加上「最多讀幾列」的硬上限:真實的亞航表只有一百多列,上限設幾千就很寬鬆了。另外,「連續 N 列全空就停」也順手加上。

嚴重度為什麼是中:要先是某個專案的稽核員或管理者,而且那一輪要在「稽核中」階段;但一旦條件成立,打的是所有客戶共用的服務,而且成本極低(幾 KB 的檔,不用做壓縮炸彈)。

4.2 C4b-2 壓縮炸彈:Excel 整份被載進記憶體

現況:已修(M12-2,FR-114 CM-2181,同上)

場景

跟 C4a-1 一樣,只是換成 Excel。上傳一份不到 10MB 的 .xlsx,裡面放工作表內容的那一段解開後有幾 GB,塞滿重複的儲存格。系統打開檔案時,會把每一格都建成物件放進記憶體,還沒輪到格式檢查,服務程序就先被砍了。

為什麼會這樣

  • 唯一的大小檢查在套件側 ar_import_app_service.py:44(_XLSX_MAX_SIZE = 10MB)與 :78-80,量的是壓縮後的檔案大小。
  • 打開檔案在套件側 ar_report_parser/registry.py:17(一般模式,不是唯讀模式),經由與 C4a 共用的 import_adapter/registry_base.py:24 呼叫。
  • 主專案側面板補查了一件事:openpyxl 預設會用 defusedxml 讀 XML,所以不會有外部實體注入(XXE)的問題,這條是單純的記憶體耗盡。

兩側評等不同的原因:套件側面板評中,主專案側評低。主專案側的研究員看的是「路由把檔案原樣交下去」這一段,面板在評影響時比較保守。兩邊都 3:0 確認問題存在。建議以套件側的中為準,因為病灶就在套件。

怎麼修:跟 C4a-1、總表第 124/127 項同一個修法:打開之前先用壓縮檔工具檢查解開後的大小與膨脹比。放在共用的 RegistryBase.parse_file 裡,C4a 的 Word 與這裡的 Excel 可以一次補好。再配合 4.1 的唯讀模式,一起降低記憶體用量。


5. 卡片點名的問題,逐條回答(runner 自行開檔核對,未經三人面板投票)

5.1 🔴 get_wf_ids_by_control_ids 的輪次編號可以不帶:呼叫端到不了「不帶」那條路,不成立

背景:這支查詢(套件側 wf_control_mapping_lookup_query.py:30-53)用控制項代號(例如 AC.L1-b.1.i,所有客戶都一樣的字串)查「哪些工作流程屬於這個控制項」。那張對照表 public.workflow_execution_control_mapping 沒有客戶欄位、也沒開資料庫隔離(DEV 實查 18:15),欄位只有 workflow_execution_id、version_id、control_id、ao_part_id、round_id。不帶輪次編號時,查詢條件就只剩控制項代號,會撈到全系統所有客戶、所有輪次的對照。

追呼叫鏈:

  1. 整個系統只有一個呼叫端:ar_import_app_service.py:430,由 _get_allowed_wf_ids_by_control 呼叫(我 grep 過兩個 repo 與主專案的 DI 組裝檔)。
  2. _get_allowed_wf_ids_by_control 只被 _build_evidence_pool(:364)呼叫;_build_evidence_pool 只被 upload_and_parse(:175-177)呼叫。
  3. 輪次編號是 getattr(round_entity, "id", None),其中 round_entity 是 :74 的 resolve_round_for_import(round_uid, user_context.id) 回傳值。
  4. 那支守門(assessment_result_app_service.py:131-142)第一行 _require_round 找不到輪次就直接丟 404,找到才回傳。所以 round_entity 不可能是空值,id 是資料庫主鍵,也不會是空值。

結論:這條路徑走不到「不帶輪次」的分支。將來如果有人新增呼叫端卻不帶輪次,就會出事,建議把參數改成必填(見第 10 節)。

補充:就算真的撈到別家客戶的對照,後果也只是「過濾範圍變寬」,不是「多給佐證」。因為對照結果只被拿來刪掉候選佐證(:385-389),候選佐證本身是從這份計畫自己的控制項樹走訪出來的(見 5.2)。

5.2 兩處「查詢失敗就回空、不擋解析」:失敗時是少給或維持原範圍,不會多給,不成立

  • _build_evidence_pool(:341):建控制項樹失敗(:356-360)就回空池,也就是「沒有任何佐證可配對」,預覽照常,佐證要稽核員自己挑。少給,不是多給。
  • _get_allowed_wf_ids_by_control(:424):查對照失敗(:429-433)也回空,這時 :385 的 if control_allowed_wfs: 不成立,不過濾。這個「不過濾」看起來像多給,但我追了候選佐證的來源:
    • 候選是從 build_control_tree_by_ssp_id(ssp_id)(:357)走訪出來的。這裡的 ssp_id 是這一輪的凍結快照計畫(round_entity.ssp_id,:176),伺服器自己解析、使用者塞不進來。
    • 所以候選池本來就只包含這份計畫底下、這個控制項的任務佐證。對照查詢只是二次過濾,用來排除「同一份檔案也掛在別的控制項」造成的誤配(檔頭註解寫的 2026-07 STG 案例)。
    • 失敗時退回的是「同一份計畫內可能有跨控制項的誤配」,不會跨計畫、跨客戶。

另外,對照表裡沒有對應資料的控制項同樣不過濾(例如 round_id 為空的舊資料,查詢註解寫明「寧缺勿錯」)。性質同上:只影響同一份計畫內的配對準確度,不是資安問題。

5.3 is_admin 放行的是誰:同 C4a 第 5.1 節,帳號層超級管理員旗標,不成立

_require_job(套件側 ar_import_app_service.py:569-576)跟 C4a 是逐字相同的寫法,結論也相同,這裡不重複。

5.4 四步是不是都有兩道檢查:都有,而且比 C4a 更嚴

守門 10 行,對上公開方法 4 支。

步驟 公開方法(套件側起點) 「是你們公司的」 「你在專案裡的角色」 階段限制
上傳+解析 upload_and_parse(:68) 工作單由伺服器建,公司取自登入身分 resolve_round_for_import:稽核員或管理者 只有「稽核中」,而且要已開始稽核
預覽 get_parse_result(:186) _require_job 同上:稽核員或管理者 同上
捨棄 discard_parse(:217) _require_job 同上:稽核員或管理者 同上
確認匯入 confirm_import(:225) _require_job resolve_round_for_import,底下每個寫入方法再各查一次 同上

跟 C4a 的差別:Excel 這邊的預覽與捨棄也要求是稽核員或管理者(C4a 只要求是專案成員)。C4a 第 5.2 節提到的「檢視者也能丟掉工作單」,在這裡不存在。

輪次歸屬同樣由伺服器決定:上傳時把網址上的 round_uid 寫進工作單的 source_uid(:82-89),之後每一步都用 job.source_uid 重查角色。

主專案側四條路由都掛了「要登入」+「客戶有買稽核模組」,並把 get_user_context() 傳進服務,接縫沒有漏接(主專案側面板也確認了)。

5.5 「檢查甲、動乙」:判定與觀察都限定在「這一輪」底下找,不成立

confirm_import 會用到三種編號,我逐一追過:

  • 判定編號:不是從前端拿的。:262-267 由伺服器依「這一輪的判定清單」建控制項對判定的對照,前端只送控制項代號。代號在這一輪找不到就跳過(:286-289)。
  • 觀察編號(綁到判定上):是這次剛建立的觀察,而 judge_finding 綁定前會用 _resolve_observation(r.ar_result_id, ...)(assessment_result_app_service.py:486-490)在這一輪的觀察裡找,找不到就 404。
  • 重匯時要刪掉的舊觀察:來自這一輪先前完成的工作單(get_completed_by_source(round_uid),repo 側 ar_xlsx_parse_job_repo_impl.py:56-65),刪除時同樣用 _resolve_observation 限定在這一輪。

唯一沒核對歸屬的是佐證資料,見第 6.1 節。

5.6 Excel 公式注入:本棒範圍內不成立

讀檔時用了 data_only=True(registry.py:17),公式儲存格讀到的是 Excel 上次存檔時算好的值,不會執行公式。寫出方向,Excel 裡的文字最後寫進觀察說明與風險說明;我在兩個 repo 找過,稽核結果與改善計畫都沒有匯出 Excel 或 CSV 的功能(有 openpyxl/csv 的服務都跟稽核結果無關)。這些文字如果之後流進別的匯出,屬於那一棒的範圍。


6. 本棒另外查到的(runner 自行開檔核對,未經投票)

6.1 C4b-3 確認匯入時,佐證資料照單全收

現況:已修(M12-9,FR-114 CM-2174,commit BE a8f1a24b7/套件 b7022b8b,1.21.0 出貨)

場景

某專案的稽核員 A 匯入一份稽核紀錄。預覽畫面上,系統自動替每筆觀察勾好佐證。A 按「確認匯入」前,把送出的資料改掉(用瀏覽器開發者工具,或直接打網址),在某筆觀察的佐證裡塞兩樣東西:

  1. 一個不屬於這一輪的檔案編號:例如他從別處(總表第 49 項那種讀取漏洞)拿到的某份檔案編號。
  2. 一個自己的網址:看起來像「稽核證據連結」,實際是釣魚頁。

伺服器照存。之後同專案的管理者 B 打開「稽核判定」頁或「改善計畫」頁,看到這筆觀察,點了佐證:

  • 點到檔案:前端直接拿那個編號去預覽(EvidencePreviewDialog.vue:98-102 → /file/pdf-preview/<編號>),B 看到的是 A 指定的檔案。
  • 點到連結:前端直接開新分頁打開 A 的網址(EvidencePreviewDialog.vue:104-105,有 noopener)。

為什麼會這樣

  • 前端送上來的 controls[].observations[].relevant_evidence(主專案 api/project/serializers/ar_import.py:38 是 fields.List(fields.Dict()),內容不驗)在套件側 ar_import_app_service.py:301 被原樣傳給 create_observation。
  • create_observation(套件側 assessment_result_app_service.py:552-580)直接存進觀察紀錄的 relevant_evidence,不核對裡面的檔案編號是不是這份計畫的佐證、連結是不是合法網址。
  • 預覽時伺服器算好的候選池(parsed_result)存在工作單裡,但確認時沒拿來比對。

影響到哪裡、為什麼只評低

  • 不是新的洞:一般的「新增/修改觀察」網址(主專案 api/project/routes/audit_round_route.py:286-311)也收 relevant_evidence 且不驗,所以這不是 Excel 匯入特有的問題。
  • 檔案那一半依附在總表第 34 項上:預覽端點本身只驗登入、不驗歸屬(第 34 項,仍是「部分修」)。A 自己本來就能直接打那支端點拿檔案,塞進觀察紀錄只是讓 B 替他點開。好在 upload_files 已開資料庫隔離(DEV 實查 18:28,用的是標準判斷函式),跨客戶的檔案在 B 那邊一樣打不開;能被指到的只有同一家客戶的檔案。
  • 連結那一半是新的小口子:稽核系統裡的「佐證連結」天然容易讓人信任,可以拿來釣同事。前端用的是 window.open,而不是 href 直接渲染,所以 javascript: 這類網址在新分頁裡不會在本站的網域下執行。
  • 要先是這個專案的稽核員或管理者,而且這一輪在「稽核中」。

怎麼修:確認匯入時,把每筆佐證拿去跟工作單裡存的候選池比對,只接受池子裡有的,或是前端手動從「這份計畫的佐證清單」挑出來的。連結欄位只接受 http/https。一般新增觀察的網址也要同樣處理(屬於 C3 那條線,建議一起修)。

6.2 解析工作單表的資料庫隔離規則寫錯(=C4a-3,不重報)

oscal.ar_xlsx_parse_jobs 跟 C4a 那張 Word 工作單表用的是同一條壞規則(scripts/sql/2026-07-03-ar-xlsx-parse-jobs.sql,出貨基線 02-schema.sql 緊接在 AP 那條之後)。DEV 實測結果已經寫在 C4a 報告第 6.1 節:子公司 152 看得到母公司 102 的 4 筆 Excel 工作單。兩張表應該一起修。


7. 資料庫隔離現況(DEV 唯讀實查,2026-09-24 18:15/18:28,查完 ROLLBACK)

表 Schema DEV 隔離開了嗎 規則數 備註
ar_xlsx_parse_jobs(Excel 解析工作單) oscal 開 1 規則是壞的,見 C4a 報告第 6.1 節
workflow_execution_control_mapping(工作流程與控制項對照) public 關 0 沒有客戶欄位。本棒唯一呼叫端一定帶輪次,現在不會出事(5.1)
job_evidences(任務佐證) compliance 關 0 佐證池由這份計畫的控制項樹走訪,不經全表查詢
assessment_observations/assessment_findings/assessment_risks(確認匯入實際寫入的三張) oscal 關 0 已由 C3 登記(總表 927 行),本棒不重報
upload_files(上傳檔案) public 開 4 用標準判斷函式,正確;讓 6.1 的檔案那一半擋在同一家客戶內

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

「工具報的兩條存在嗎」:可信度高。兩次掃描共 3 個候選,9 票全數投出、全部 3:0 成立,嚴重度都沒有被調降。壓縮炸彈兩側各自掃到,列數無上限那條我另外在本機實測過。

「第 5、6 節」是 runner 自行開檔核對的,沒有經過三人面板投票。關鍵事實都實查過:

  • 輪次編號不帶的路徑:兩個 repo 加 DI 組裝檔 grep 呼叫端,逐層追到守門。
  • 「失敗回空」的後果:追過候選池的來源(ssp_id 由伺服器解析)。
  • 佐證照單全收:追過前端送出、後端存入、前端讀回點開三段。
  • 資料庫隔離:DEV 唯讀實查(18:15、18:28)。

「只有這些嗎」不保證。

  1. 用的是最快的檔位(effort low),只跑研究員一輪加投票一輪,沒有威脅建模和廣度掃描。
  2. 兩側研究員彼此看不到對方。主專案四條路由與套件四支服務方法之間的接縫、套件與 AssessmentResultAppService 六支寫入方法之間的接縫,都是我手動核對的。
  3. 列數無上限只在本機單獨跑解析器那段,壓縮炸彈沒有實際做檔去打。全程沒有打任何網址。

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

項目 套件側 主專案側
掃描範圍 18 檔/1,469 行 1 檔/116 行
基準 commit eafc7ae511d5(dirty) c0673705026e(dirty)
檔位 effort low effort low,focus 生產程式碼
研究員 派 1 支,回 1 支 派 2 支(含密鑰專項),回 2 支
候選 2 條,去重後 2 條 1 條
投票 6 票全數投出 3 票全數投出
票型 F1 3:0 中(列數無上限,把握度高);F2 3:0 中(壓縮炸彈) F1 3:0 低(壓縮炸彈)
驗證章 verified(CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json) verified(CLAUDE-SECURITY-REVISION-c0673705026e-dirty.json)
工具 run ID wf_153effec-460 wf_c7e16145-981
耗時 約 18 分鐘(7 個 agent,零失敗) 約 9 分鐘(5 個 agent,零失敗;1 個回空結果,不影響候選數)
工具原始報告 套件 repo CLAUDE-SECURITY-20260924-100504/(未入版控) BE repo CLAUDE-SECURITY-20260924-102701/(未入版控)

工具報告的 F 編號對到本報告:套件側 F1=第 4.1 節,套件側 F2 與主專案側 F1=第 4.2 節。密鑰專項沒有撿到任何東西。


10. 待首腦裁決

  1. C4b-1(列數無上限)要不要單獨登記:建議要。這是 Excel 解析特有的問題,而且成本比壓縮炸彈還低(不用做特殊檔案,幾 KB 就夠)。修法(唯讀模式、iter_rows、列數上限)可以跟 C4b-2 開同一張卡。
  2. C4b-2 壓縮炸彈併進第 124/127 項那張卡:與 C4a 報告第 10 節第 1 項一起裁。建議卡上寫明「四個入口、兩個 repo」:SSP Word 與 SSP Excel 在主專案,稽核計畫 Word 與稽核結果 Excel 在套件的 RegistryBase。
  3. C4b-3(佐證照單全收)要不要登記:建議登記為低,並寫明依附第 34 項。檔案那一半要等第 34 項補上歸屬檢查才真正關得起來;連結那一半只收 http/https 就夠了。一般新增觀察的網址是同一個病,建議一起修。
  4. (可選)get_wf_ids_by_control_ids 的 round_id 改成必填:現在不會出事(5.1),但那張表沒有客戶欄位、也沒開隔離。將來新增呼叫端時忘了帶,就會變成全系統查詢。改成必填的成本是一行。
  5. 兩張解析工作單表的隔離規則:與 C4a 報告第 10 節第 3 項合併裁,不另開。