範圍:19 檔/1,585 行,跨兩個 repo。套件側
jedi-compliance-audit18 檔/1,469 行,包括 Excel 匯入服務、Excel 解析器與註冊表、框架對照表、評估目標對齊、判定彙總、佐證配對、兩支與 C4a 共用的接縫檔,以及解析工作單的資料層六支(含工作流程與控制項的對照查詢);主專案側 1 檔/116 行(api/project/routes/ar_import_route.py)。 掃描工具:Claude Code 官方claude-securityplugin,effort low。兩側各跑一次,因為工具只認得它所在那個 repo 的檔案。 掃描基準:套件側eafc7ae511d5(monorepo 主 checkout,feature/review);主專案側c0673705026e(feature/review)。兩邊工作區都有其他 session 還沒 commit 的改動。本機 BE 載入的是修正分支 worktree 裡的套件;本棒三支核心檔(服務、解析器、對照查詢)我逐一比對過,內容與主 checkout 一致。 驗證章:兩次都是 verified。只掃不修,兩個 repo 的程式碼都沒改。
權限檢查跟 C4a 一樣齊全;卡片最擔心的「佐證撈到別的輪次或別家客戶」,追下去三條都不成立。真正的問題還是出在「上傳的 Excel 怎麼被打開」:打開的方式比 Word 還危險,一份不到 5KB 的檔就能吃掉好幾百 MB 記憶體。
卡片點名要追的三件逐一查過,全部排除,見第 5 節。
「稽核結果 Excel 匯入」讓稽核員把外部做好的稽核紀錄表(亞航 CMMC Level 1 格式)上傳進系統。系統會自動讀出每個控制項的判定(符合/不符合)、查核方法、引用的佐證,替每個控制項建觀察紀錄、下判定,不符合的再自動開一筆風險。
流程跟 C4a 一樣分四步:上傳+解析 → 預覽 → 捨棄 → 確認匯入。這一棒比 C4a 多了一件事:自動配對佐證。系統會拿 Excel 裡寫的佐證檔名,去比對這個控制項底下已經上傳過的檔案,自動勾選看起來相符的,讓稽核員不用一個個挑。
所以除了 C4a 那三個問題,這一棒還要回答:自動配對佐證時撈的範圍,有沒有可能撈到別的輪次、別的專案、別家客戶的檔案?
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 | 誰掃到的 | 跟總表的關係 |
|---|---|---|---|---|---|---|---|
| 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 項上 |
現況:已修(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 的檔,不用做壓縮炸彈)。
現況:已修(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 呼叫。兩側評等不同的原因:套件側面板評中,主專案側評低。主專案側的研究員看的是「路由把檔案原樣交下去」這一段,面板在評影響時比較保守。兩邊都 3:0 確認問題存在。建議以套件側的中為準,因為病灶就在套件。
怎麼修:跟 C4a-1、總表第 124/127 項同一個修法:打開之前先用壓縮檔工具檢查解開後的大小與膨脹比。放在共用的 RegistryBase.parse_file 裡,C4a 的 Word 與這裡的 Excel 可以一次補好。再配合 4.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。不帶輪次編號時,查詢條件就只剩控制項代號,會撈到全系統所有客戶、所有輪次的對照。
追呼叫鏈:
ar_import_app_service.py:430,由 _get_allowed_wf_ids_by_control 呼叫(我 grep 過兩個 repo 與主專案的 DI 組裝檔)。_get_allowed_wf_ids_by_control 只被 _build_evidence_pool(:364)呼叫;_build_evidence_pool 只被 upload_and_parse(:175-177)呼叫。getattr(round_entity, "id", None),其中 round_entity 是 :74 的 resolve_round_for_import(round_uid, user_context.id) 回傳值。assessment_result_app_service.py:131-142)第一行 _require_round 找不到輪次就直接丟 404,找到才回傳。所以 round_entity 不可能是空值,id 是資料庫主鍵,也不會是空值。結論:這條路徑走不到「不帶輪次」的分支。將來如果有人新增呼叫端卻不帶輪次,就會出事,建議把參數改成必填(見第 10 節)。
補充:就算真的撈到別家客戶的對照,後果也只是「過濾範圍變寬」,不是「多給佐證」。因為對照結果只被拿來刪掉候選佐證(:385-389),候選佐證本身是從這份計畫自己的控制項樹走訪出來的(見 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),伺服器自己解析、使用者塞不進來。另外,對照表裡沒有對應資料的控制項同樣不過濾(例如 round_id 為空的舊資料,查詢註解寫明「寧缺勿錯」)。性質同上:只影響同一份計畫內的配對準確度,不是資安問題。
is_admin 放行的是誰:同 C4a 第 5.1 節,帳號層超級管理員旗標,不成立_require_job(套件側 ar_import_app_service.py:569-576)跟 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() 傳進服務,接縫沒有漏接(主專案側面板也確認了)。
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 節。
讀檔時用了 data_only=True(registry.py:17),公式儲存格讀到的是 Excel 上次存檔時算好的值,不會執行公式。寫出方向,Excel 裡的文字最後寫進觀察說明與風險說明;我在兩個 repo 找過,稽核結果與改善計畫都沒有匯出 Excel 或 CSV 的功能(有 openpyxl/csv 的服務都跟稽核結果無關)。這些文字如果之後流進別的匯出,屬於那一棒的範圍。
現況:已修(M12-9,FR-114 CM-2174,commit BE a8f1a24b7/套件 b7022b8b,1.21.0 出貨)
場景
某專案的稽核員 A 匯入一份稽核紀錄。預覽畫面上,系統自動替每筆觀察勾好佐證。A 按「確認匯入」前,把送出的資料改掉(用瀏覽器開發者工具,或直接打網址),在某筆觀察的佐證裡塞兩樣東西:
伺服器照存。之後同專案的管理者 B 打開「稽核判定」頁或「改善計畫」頁,看到這筆觀察,點了佐證:
EvidencePreviewDialog.vue:98-102 → /file/pdf-preview/<編號>),B 看到的是 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 匯入特有的問題。upload_files 已開資料庫隔離(DEV 實查 18:28,用的是標準判斷函式),跨客戶的檔案在 B 那邊一樣打不開;能被指到的只有同一家客戶的檔案。window.open,而不是 href 直接渲染,所以 javascript: 這類網址在新分頁裡不會在本站的網域下執行。怎麼修:確認匯入時,把每筆佐證拿去跟工作單裡存的候選池比對,只接受池子裡有的,或是前端手動從「這份計畫的佐證清單」挑出來的。連結欄位只接受 http/https。一般新增觀察的網址也要同樣處理(屬於 C3 那條線,建議一起修)。
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 工作單。兩張表應該一起修。
| 表 | 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 的檔案那一半擋在同一家客戶內 |
「工具報的兩條存在嗎」:可信度高。兩次掃描共 3 個候選,9 票全數投出、全部 3:0 成立,嚴重度都沒有被調降。壓縮炸彈兩側各自掃到,列數無上限那條我另外在本機實測過。
「第 5、6 節」是 runner 自行開檔核對的,沒有經過三人面板投票。關鍵事實都實查過:
ssp_id 由伺服器解析)。「只有這些嗎」不保證。
AssessmentResultAppService 六支寫入方法之間的接縫,都是我手動核對的。| 項目 | 套件側 | 主專案側 |
|---|---|---|
| 掃描範圍 | 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 節。密鑰專項沒有撿到任何東西。
iter_rows、列數上限)可以跟 C4b-2 開同一張卡。RegistryBase。http/https 就夠了。一般新增觀察的網址是同一個病,建議一起修。get_wf_ids_by_control_ids 的 round_id 改成必填:現在不會出事(5.1),但那張表沒有客戶欄位、也沒開隔離。將來新增呼叫端時忘了帶,就會變成全系統查詢。改成必填的成本是一行。