C4a 掃描報告 — 稽核計畫 Word 匯入(CM-2103)

範圍:14 檔/1,271 行,跨兩個 repo 配對切。套件側 jedi-compliance-audit 13 檔/1,155 行(Word 匯入服務、Word 解析器與註冊表、兩支共用接縫、解析工作的實體/介面/domain service/repo/mapper/資料表定義);主專案側 1 檔/116 行(api/project/routes/ap_docx_import_route.py)。 掃描工具:Claude Code 官方 claude-security plugin,effort low,兩側各跑一次(工具只認它所在那個 repo 的檔案)。 掃描基準:套件側 eafc7ae511d5(monorepo 主 checkout,feature/review);主專案側 6dcec98940f1(feature/review)。兩邊工作區都有其他 session 還沒 commit 的改動。本機 BE 實際載入的是修正分支 worktree 的套件原始碼(.venv 裡的 .pth 指過去),本棒 13 支檔案我逐一比對過,兩份內容一樣。 驗證章:兩次都是 verified。只掃不修,兩個 repo 都沒改程式碼。


1. 一句話結論

四個步驟的權限檢查形狀是對的,卡片擔心的 is_admin 放行也不成立;真正的問題出在「上傳的 Word 檔怎麼被打開」,以及資料庫那道第二防線寫錯了。

  • 工具報兩條,兩側各自掃到同一組(面板 12 票全投,全數 3:0):
    • 壓縮炸彈(中):Word 檔只量壓縮後的大小(20MB),解開後多大沒人管,一份小檔就能把伺服器記憶體吃光。這跟總表第 127 項(SSP 的 Word 匯入)是同一個病,只是換了一個入口;但病灶在套件側的共用讀檔器,第 127 項的修法修的是主專案那支,這裡不會跟著好。兩個修正分支上都還沒有任何一處檢查解開後的大小。
    • 比對規則卡死(低):解析器裡有三條比對規則,碰到特製的超長文字會卡住 CPU。我在本機實測,4 萬個空白、不到 40KB 的標題就讓其中一條跑了 21 秒,花的時間跟長度的平方成正比,放大幾倍就超過 120 秒逾時。
  • 我自己查到、工具沒報的一條(未經投票):這兩張「解析工作單」表的資料庫隔離規則,是 5 月就被判定「三處全壞」、SSP 那張已經修掉的舊寫法。7 月新建這兩張表時照抄了壞掉的版本,一直沒人發現。DEV 實測:子公司的帳號看得到母公司的匯入工作單。現在靠程式那一關擋住,所以不是能直接打穿的洞,但第二道防線是壞的(第 6.1 節)。C4b 的 Excel 解析工作單表用的是同一條壞規則。

卡片點名的其他幾件逐一查過,都不成立:確認匯入時只檢查了甲、改的卻是乙;讀的路沒守;Word 公式注入。第 5 節逐條說明。


2. 這一棒在檢查什麼

「稽核計畫 Word 匯入」是讓稽核員上傳一份 Word 格式的稽核計畫,系統自動讀出行程、稽核方法、稽核單位,填進系統裡的稽核計畫。流程分四步:

  1. 上傳+解析:稽核員上傳 Word 檔,系統當場解析,結果存成一張「解析工作單」。
  2. 預覽:畫面讀這張工作單,讓使用者看解析結果、刪改。
  3. 捨棄:不要了就丟掉這張工作單。
  4. 確認匯入:把預覽裡留下的行程寫進稽核計畫。

這一棒要回答三件事:

  • 每一步是不是都有兩道檢查:一道是「你在這個專案裡是什麼角色」,一道是「這張解析工作單是不是你們公司的」?
  • 解析工作單上的「屬於哪份計畫」是伺服器記的,還是使用者送的?確認時寫進去的東西,有沒有核對歸屬?
  • 上傳的 Word 檔在解析時,有沒有被灌爆、被卡死、被夾帶公式的風險?

3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 嚴重度 誰掃到的 跟總表的關係
C4a-1 上傳的 Word 檔只量壓縮後大小,解開時整份塞進記憶體(壓縮炸彈) 一份 20MB 以內的檔解開成幾十 GB,服務程序被系統砍掉;連送幾份,全部客戶一起停擺 某個專案的稽核員或管理者,稽核計畫還在規劃階段 套件側 import_adapter/registry_base.py:23-24(parse_file 打開檔案之前) 中 套件側、主專案側各自掃到 新入口,與第 127 項同病同修法,但病灶在套件、第 127 項的修法修不到這裡
C4a-2 解析器三條比對規則遇到超長文字會卡死 CPU 一份幾十 KB 的檔卡住一個服務程序直到逾時;四份同時送,預設的四個程序全被占滿 同上,且文件要被判定成亞航格式(至少五個段落標題) 套件側 ap_report_parser/airasia_cmmc_l1_v1.py:57、:62、:107-113(三條規則的定義處),以及 :205 起的 _split_sections(該加長度上限的地方) 低(面板評) 套件側報一條、主專案側報另兩條,我在本機實測過 新,形狀與第 172 項(PDF 比對規則)相同
C4a-3 兩張解析工作單表的資料庫隔離規則寫錯(5 月修掉的舊寫法又被抄了一次) 子公司的帳號在資料庫層看得到母公司的工作單;使用者編號剛好等於某家公司編號時,也會看到那家的 現在要再配一條程式層漏掉的路才打得到,目前沒找到 主專案側 scripts/sql/2026-07-02-ap-docx-parse-jobs.sql:40-42(規則本體),以及出貨基線 scripts/init/02-schema.sql:24943 低(第二防線壞掉,第一防線還在) runner 自行核對,未經投票;DEV 實測 新。總表把這兩張表算成「有開隔離」,其實開了但規則是壞的

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

4.1 C4a-1 上傳一份「壓縮炸彈」Word 檔,就能讓整個產品停擺

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

場景

稽核員在稽核計畫頁按「匯入 Word」,選一份自己做的 .docx。這份檔不到 20MB,但裡面放正文的那一段(word/document.xml)是一大串重複內容,壓縮比可以到上千比一,解開後是幾十 GB。系統收到檔案,檢查「小於 20MB」後放行,接著打開檔案準備解析。打開時會把那一段整個解進記憶體,服務程序記憶體不夠就被作業系統砍掉。連送幾次,預設四個服務程序全倒,所有客戶都連不上。如果資料庫跟服務放在同一台機器,記憶體耗盡時資料庫也可能一起被砍。

為什麼會這樣

  • 唯一的大小檢查在套件側 ap_docx_import_app_service.py:40(_DOCX_MAX_SIZE = 20MB)與 :76-78,量的是上傳上來、壓縮著的檔案大小。
  • 真正打開檔案的是套件側 import_adapter/registry_base.py:24 的 self._loader(file_path),Word 匯入時這個讀檔器是 docx.Document(ap_report_parser/registry.py:26)。這套程式庫會把 Word 裡每一段都完整解開放進記憶體,中間沒有任何一關問「解開後多大」。
  • 全站 50MB 的上傳上限(core/app_factory.py:104)量的也是壓縮後的大小,擋不到。

跟總表第 127 項的關係

第 127 項是 SSP 的 Word 匯入(主專案 app/oscal/service/ssp_docx_import_app_service.py),病一模一樣。但本條的讀檔入口是套件裡的 RegistryBase,那是另一份程式碼。第 127 項的修法只要照總表說的寫在主專案那支服務裡,這裡就不會跟著修好。我查過兩個修正分支(主專案 fix/security-b1 與套件 fix/security-b1 worktree),都還沒有任何地方用 zipfile 檢查解開後的大小。

怎麼修

在套件 RegistryBase.parse_file 呼叫讀檔器之前,先用壓縮檔工具讀出每一段解開後的大小、總大小、膨脹比、段數,超過上限就拒收。放在這一層,C4b 的 Excel 匯入也共用同一個 RegistryBase,可以一起受惠(C4b 會再確認)。建議跟第 124/127 項開成同一張卡、共用同一支檢查函式。

嚴重度為什麼是中:要先是某個專案的稽核員或管理者,而且稽核計畫要還在規劃階段;但一旦條件成立,打的是所有客戶共用的服務。

4.2 C4a-2 解析器三條比對規則,遇到特製的超長文字會卡住

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

場景

同樣是稽核員上傳 Word 檔。這份檔湊齊了亞航格式要的五個段落標題(依據、目的、稽核單位、稽核日期、稽核範圍),騙過格式判斷。接著在標題那一行塞一個「A」、後面接幾萬個空白、最後一個「B」。系統整理標題時,要檢查結尾是不是日期戳記,這條檢查會卡在那一大串空白裡來回試。不到 40KB 的文字就能卡 20 秒以上,放大幾倍就一路卡到 120 秒逾時、服務程序被砍。四份同時送,預設四個程序全占滿。

是哪三條

比對規則 定義位置(套件側) 吃的是哪段文字 誰報的
_ORG_RE(抓「由○○協助」) airasia_cmmc_l1_v1.py:57,在 :234 使用 稽核單位段 套件側面板 3:0
_TITLE_DATE_STAMP_RE(剝標題結尾的日期) :107-113,在 _titleize 使用 標題(第一個非空段落) 主專案側面板 3:0
_CLOCK_RE(抓開始時間) :62,在 _parse_start_datetime 使用 稽核日期段剩下的時段文字 同上

本機實測(只在本機跑那三條比對規則,沒有打任何網址):

重複字元數 稽核單位規則 標題日期規則 時間規則
5,000 0.13 秒 1.27 秒 0.45 秒
10,000 0.51 秒 5.14 秒 1.82 秒
20,000 2.05 秒 20.69 秒 7.38 秒

長度加倍、時間變四倍,確認跟長度的平方成正比。標題那條在「20,000」這列用了 4 萬個空白,Word 壓縮後只剩幾 KB。解析器的段落文字沒有任何長度上限(_split_sections 從 :205 起直接把段落用換行接起來)。

怎麼修:段落文字進比對規則之前先截長度(標題、稽核單位、稽核日期這幾段,正常都不會超過幾百字)。三條規則本身也改寫成不會回頭重試的寫法,例如先把連續空白壓成一個、只看標題最後幾十個字、稽核單位規則改成逐行比對。

嚴重度為什麼是低(面板評):條件跟 4.1 一樣,而且一次只卡一個程序、逾時就會被回收。但實測的量級跟總表第 172 項(PDF 那條,五頁 52 秒)差不多,首腦可以考慮比照第 172 項登記。


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

5.1 _require_job 的 is_admin 放行的是平台管理員嗎?是帳號層的「超級管理員」旗標,不是租戶管理員角色,不成立

套件側 ap_docx_import_app_service.py:251-258 的 _require_job:工作單不存在或已停用就 404;工作單的公司跟你的公司不同,而且你不是 is_admin,也 404。

is_admin 從哪來:jedi-iam/jedi_iam/middleware/context.py:130 組使用者身分時寫的是 is_admin=user.is_super_admin,也就是帳號本身的「超級管理員」旗標。不是角色表上那個「管理員角色」旗標(roles.is_admin,每家公司的 System Manager 角色都有)。所以一般客戶的管理員拿不到這個放行。

有兩點要讓首腦知道:

  • 旗標不限總部帳號:DEV 有兩個帳號帶這個旗標,admin(總部,公司 1)與 blsadmin(子公司 102)。我找過,整個系統沒有任何網址或畫面能設這個旗標(jedi-iam/api/serializers/user.py:43 明寫不收),只能直接改資料庫。所以 blsadmin 應該是測試資料,不是誰都能給自己加上的。
  • 就算拿到了也繞不過去:子公司的超級管理員帶著別家公司的工作單編號來,在 _require_job 讀工作單那一步,資料庫隔離就先把那一列藏起來了(第 6.1 節那條壞規則,剛好在這個情況下是擋住的),拿到的是空值、回 404。後面還有一道「必須是那個專案的成員」。

5.2 四步是不是都有兩道檢查?都有,形狀是對的

守門次數 8 行對上公開方法 4 支,每一支都有:

步驟 公開方法(套件側起點) 「是你們公司的」 「你在專案裡的角色」 階段限制
上傳+解析 upload_and_parse(:66) 工作單由伺服器建立,公司編號取自登入身分 resolve_ap_and_check_auditor:稽核員或管理者 只有規劃階段
預覽 get_parse_result(:144) _require_job resolve_ap_and_check_participant:專案成員就行 不限
捨棄 discard_parse(:172) _require_job 同上:專案成員就行 不限
確認匯入 confirm_import(:180) _require_job resolve_ap_and_check_auditor(set_tasks、add_ap_party 裡面又各查一次) 只有規劃階段

關鍵是**「這張工作單屬於哪份稽核計畫」是伺服器記的**:上傳時用網址上的 ap_uid 寫進工作單的 source_uid(:80-86),之後每一步都用 job.source_uid 重查角色,不收使用者另外送來的計畫編號。所以不會出現「用 A 計畫的權限去操作 B 計畫」的情況。

主專案側四條路由(ap_docx_import_route.py:39-40、:63-64、:79-80、:100-101)都掛了「要登入」+「客戶有買稽核模組」,而且都把 get_user_context() 傳進服務,接縫沒有漏接。

小觀察(不算漏洞):「捨棄」是寫入動作,守門卻用了讀取的標準,專案裡的「檢視者」「觀察者」也能丟掉稽核員還沒確認的工作單。影響只到同一個專案、而且只是軟刪一張暫存單,重新上傳就回來了。要不要收緊成跟上傳一樣(稽核員或管理者),交給首腦決定。

5.3 確認匯入時有沒有「檢查甲、動乙」?計畫本身沒有,但寫進去的稽核人員編號沒核對歸屬(不構成資安問題)

計畫本身不會:確認匯入動的計畫就是 job.source_uid,與守門查的是同一份。

但前端送上來的兩種編號,伺服器都直接照寫、沒問「是不是這份計畫的稽核人員」:parties[].matched_party_uid(:206-208)與 tasks[].participants[].party_uuid。它們一路寫進行程參與人員表(主專案 app/flow_control/service/assessment_plan_app_service.py:620-625)。

我追了寫進去之後會被怎麼用:讀回來時只回 {party_uuid, role_id} 兩個欄位(同檔 :262-265),不會拿這個編號去撈人名、email。複製到下一輪時也只是照抄(:457-460)。所以塞別家的編號只會留下一筆指向不明的連結,讀不到別人的資料,也改不到別人的東西。這屬於資料正確性問題,不列資安條目。一般的「設定行程」網址(不經過 Word 匯入)也是同樣的寫法,不是這條匯入路線特有。

5.4 讀取類「憑什麼給你看」:預覽要求是專案成員,成立

預覽會把解析結果、該計畫的稽核人員清單都回給前端。稽核人員清單本身在稽核計畫頁就是「專案成員都看得到」(list_ap_parties 的註解與行為都是這樣),沒有多給。沒有「清單所有工作單」這種方法,也就沒有「列出別人的工作單」這條路。

5.5 repo 方法有沒有「除了編號還限定範圍」:只認編號,但每個呼叫點前面都已經驗過歸屬

ap_docx_parse_job_repo_impl.py:36 的 update 只用內部 id 找、:50 的 deactivate 只用 uid 找,都沒有再加「屬於哪家公司」。但這兩支的每一個呼叫點,前面都已經過 _require_job,或者用的是剛剛才建立的工作單(上傳流程)。所以現在不構成「檢查甲、動乙」。將來如果有人新增呼叫點卻沒先驗歸屬,就會出事,而資料庫那一關又是壞的(第 6.1 節)。

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

公式注入要成立,得先有「把這段文字寫進 Excel」的出口。Word 解析出來的文字寫進的是稽核計畫的行程標題與說明。我在主專案找過,稽核計畫沒有匯出 Excel 的功能(assessment_plan_app_service.py 全檔沒有 xlsx 相關程式)。這些文字如果之後流進別的匯出(例如稽核報告),屬於那一棒的範圍。前端的匯入元件沒有用 v-html 直接渲染內容。


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

6.1 C4a-3 兩張解析工作單表的資料庫隔離規則是 5 月就被判定壞掉的寫法

現況:已修(M12-7,FR-114 CM-2195,BE commit 56dd4cdc8;出貨基線另由 M11-41/CM-2215 補,1.21.0 出貨)

場景

一家集團客戶,總公司是 102,底下有子公司 152。總公司的稽核員在系統上傳了幾份稽核計畫 Word 檔,系統存成解析工作單。子公司 152 的任何一個帳號登入後,如果系統有任何一條路漏掉程式層的公司檢查,直接查這張表,就會看到總公司那幾份工作單的完整解析結果:稽核範圍、稽核依據、稽核單位名稱、行程時段。目前我沒找到這樣一條路(Word 匯入四步都有 _require_job),所以它不是現在就打得穿的洞。它的問題是:這張表的資料庫防線其實是壞的,總表卻把它記成「有開隔離」。

規則長什麼樣

-- scripts/sql/2026-07-02-ap-docx-parse-jobs.sql:40-42(出貨基線 02-schema.sql:24943 同一條)
USING (tenant_id::text = current_setting('app.user_id', true)
       OR current_setting('app.allowed_tenant_paths', true) LIKE '%' || tenant_id::text || '%')

這條規則錯在三處。5 月 1 日已經有人把這三處寫成文字,並修好了 SSP 那張同名表(scripts/sql/2026-05-01-fix-ssp-docx-parse-jobs-rls.sql:4-8):

  1. 拿「公司編號」去比「使用者編號」:使用者編號剛好等於某家公司的編號時,就會看到那家公司的工作單。
  2. 用「包含這串數字」比對公司路徑:子公司的路徑 /1/102/152/ 裡面含有 102,所以子公司看得到母公司的工作單;公司 2 也會被路徑 /12/ 誤判成符合。
  3. 沒有「超級管理員/系統作業」的放行條件:平台管理員在資料庫層反而看不到子公司的工作單。這跟程式層 _require_job 放行 is_admin 的設計互相矛盾,只是不會造成外洩。

7 月 2 日建稽核計畫 Word 匯入這張表時,照抄了修正前的寫法。C4b 的 Excel 工作單表(ar_xlsx_parse_jobs)也是同一條。還有一件事:9 月 12 日的 CM-1664 migration(scripts/sql/2026-09-12-cm1664-users-rls-self-visible-textcmp.sql:27-28)還把這兩張表當成「既有同類規則的作法」來參考。

DEV 實測(2026-09-24 17:29,用受隔離的 cm_app 身分、唯讀交易,手動設 session 變數模擬各種登入身分,查完 ROLLBACK):

模擬的身分 稽核計畫工作單(全部 13 筆都屬於公司 102) Excel 工作單(4 筆) SSP 工作單(已修好的規則,對照組)
公司 102 的人 13 4 28
子公司 152 的人 13(應為 0) 4(應為 0) 0
兄弟公司 131 的人 0 0 0
公司 131 的人,但使用者編號剛好是 102 13(應為 0) 4(應為 0) 0
沒有公司路徑 0 0 0

我把 DEV 九家公司兩兩比對,壞規則讓每一家子公司都看得到上面每一層母公司的資料;正確規則只會給自己和底下的子公司。

怎麼修:照抄 5 月那支修正 migration 的寫法,改用 app_tenant_allowed_for_session(tenant_id),並加上超級管理員的放行條件。兩張表一起改,出貨基線也要跟著重產。

嚴重度為什麼是低:程式層每一步都有公司檢查,目前沒有能直接利用的路。但它是「第二道防線以為有、其實沒有」:哪天程式層漏一處,資料就一路通到母公司。總表 927 行把這兩張表列為「有開隔離」,應該改掉。


7. 資料庫隔離現況(DEV 唯讀實查,2026-09-24 16:55/17:05/17:29,查完 ROLLBACK)

表 Schema DEV 隔離開了嗎 規則 規則對不對
ap_docx_parse_jobs(稽核計畫 Word 工作單) oscal 開 1 條(ALL) 錯,見第 6.1 節
ar_xlsx_parse_jobs(稽核結果 Excel 工作單,C4b 用) oscal 開 1 條(ALL) 錯,同一條
對照:ssp_docx_parse_jobs、ssp_excel_parse_jobs、framework_parse_jobs oscal 開 各 4 條 對,用標準判斷函式

確認匯入時實際寫入的稽核計畫本體與行程表(oscal.assessment_plans、ap_tasks、ap_task_participants 等)隔離全關,這點總表 927 行已經登記,本棒不重報。


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

「工具報的兩條存在嗎」:可信度高。兩次掃描共 4 個候選、12 票全數投出,4 條全部 3:0 成立,嚴重度都沒被調降。兩側的研究員彼此看不到對方,卻各自掃到壓縮炸彈,互相印證。比對規則那條我另外在本機實測過。

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

  • is_admin 的來源:開 jedi-iam 原始碼確認。帶旗標的帳號:DEV 實查(17:28)。
  • 壞掉的隔離規則:讀 migration 原文與 5 月修正檔,DEV 用 cm_app 模擬五種身分實測(17:29)。
  • 行程參與人員編號寫進去之後怎麼用:逐一追過讀取與複製兩處。
  • 兩個修正分支都沒有壓縮炸彈檢查:逐一 grep 過。

「只有這些嗎」:不保證。

  1. 用的是最快的檔位(effort low),只跑研究員一輪加投票一輪,沒有威脅建模和廣度掃描。
  2. 兩側研究員彼此看不到對方。跨側接縫(主專案四條路由 ↔︎ 套件四支服務方法,以及套件 ↔︎ 主專案 AssessmentPlanAppService 的四支守門與寫入方法)是我手動核對的。
  3. 壓縮炸彈沒有實際做檔去打,比對規則只在本機單獨跑那三條規則,全程沒有打任何網址。

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

項目 套件側 主專案側
掃描範圍 13 檔/1,155 行 1 檔/116 行
基準 commit eafc7ae511d5(dirty) 6dcec98940f1(dirty)
檔位 effort low effort low,focus 生產程式碼
研究員 派 1 支,回 1 支 派 2 支(含密鑰專項),回 2 支
候選 2 條,去重後 2 條 2 條,去重後 2 條
投票 6 票全數投出 6 票全數投出
票型 F1 3:0 中(壓縮炸彈);F2 3:0 低(稽核單位規則) F1 3:0 中(壓縮炸彈);F2 3:0 低(標題日期規則+時間規則)
驗證章 verified(CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json) verified(CLAUDE-SECURITY-REVISION-6dcec98940f1-dirty.json)
工具 run ID wf_158edbd4-e79 wf_8a9042c1-842
耗時 約 13 分鐘(7 個 agent,零失敗) 約 22 分鐘(8 個 agent,零失敗;1 個回空結果,不影響候選數)
工具原始報告 套件 repo CLAUDE-SECURITY-20260924-085007/(未入版控) BE repo CLAUDE-SECURITY-20260924-090417/(未入版控)

工具報告的 F 編號對到本報告:兩側 F1=第 4.1 節;套件側 F2 與主專案側 F2 合併成第 4.2 節(三條規則)。密鑰專項沒有撿到任何東西。


10. 待首腦裁決

  1. C4a-1 壓縮炸彈要不要併進第 124/127 項那張卡(建議要,而且要寫明:病灶有三個入口、分在兩個 repo。SSP Word/Excel 在主專案,稽核計畫 Word 與稽核結果 Excel 在套件的 RegistryBase。檢查要放在第一次打開檔案之前)。
  2. C4a-2 比對規則卡死要不要比照第 172 項登記:面板評低,但實測量級與第 172 項相當。
  3. C4a-3 兩張解析工作單表的隔離規則要不要開修正卡(建議要:照抄 5 月 SSP 那支修正 migration,兩張表一起修,出貨基線要重產)。總表 927 行「五張表開了隔離」那句,建議改成「三張正確、兩張規則壞掉」。
  4. (可選)「捨棄解析工作單」要不要收緊到稽核員或管理者(第 5.2 節小觀察):影響只到同一專案,屬產品規則。
  5. (可選)DEV 的 blsadmin(子公司 102)帶超級管理員旗標:確認是不是刻意的測試資料。這個旗標在程式很多地方被當成「平台管理員」使用,只要出現在子公司帳號上,每個讀它的地方都要重新想一次(第 5.1 節)。