範圍:14 檔/1,271 行,跨兩個 repo 配對切。套件側
jedi-compliance-audit13 檔/1,155 行(Word 匯入服務、Word 解析器與註冊表、兩支共用接縫、解析工作的實體/介面/domain service/repo/mapper/資料表定義);主專案側 1 檔/116 行(api/project/routes/ap_docx_import_route.py)。 掃描工具:Claude Code 官方claude-securityplugin,effort low,兩側各跑一次(工具只認它所在那個 repo 的檔案)。 掃描基準:套件側eafc7ae511d5(monorepo 主 checkout,feature/review);主專案側6dcec98940f1(feature/review)。兩邊工作區都有其他 session 還沒 commit 的改動。本機 BE 實際載入的是修正分支 worktree 的套件原始碼(.venv裡的.pth指過去),本棒 13 支檔案我逐一比對過,兩份內容一樣。 驗證章:兩次都是 verified。只掃不修,兩個 repo 都沒改程式碼。
四個步驟的權限檢查形狀是對的,卡片擔心的 is_admin 放行也不成立;真正的問題出在「上傳的 Word 檔怎麼被打開」,以及資料庫那道第二防線寫錯了。
卡片點名的其他幾件逐一查過,都不成立:確認匯入時只檢查了甲、改的卻是乙;讀的路沒守;Word 公式注入。第 5 節逐條說明。
「稽核計畫 Word 匯入」是讓稽核員上傳一份 Word 格式的稽核計畫,系統自動讀出行程、稽核方法、稽核單位,填進系統裡的稽核計畫。流程分四步:
這一棒要回答三件事:
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 | 誰掃到的 | 跟總表的關係 |
|---|---|---|---|---|---|---|---|
| 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 實測 | 新。總表把這兩張表算成「有開隔離」,其實開了但規則是壞的 |
現況:已修(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 裡每一段都完整解開放進記憶體,中間沒有任何一關問「解開後多大」。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 項開成同一張卡、共用同一支檢查函式。
嚴重度為什麼是中:要先是某個專案的稽核員或管理者,而且稽核計畫要還在規劃階段;但一旦條件成立,打的是所有客戶共用的服務。
現況:已修(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 項登記。
_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 角色都有)。所以一般客戶的管理員拿不到這個放行。
有兩點要讓首腦知道:
admin(總部,公司 1)與 blsadmin(子公司 102)。我找過,整個系統沒有任何網址或畫面能設這個旗標(jedi-iam/api/serializers/user.py:43 明寫不收),只能直接改資料庫。所以 blsadmin 應該是測試資料,不是誰都能給自己加上的。_require_job 讀工作單那一步,資料庫隔離就先把那一列藏起來了(第 6.1 節那條壞規則,剛好在這個情況下是擋住的),拿到的是空值、回 404。後面還有一道「必須是那個專案的成員」。守門次數 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() 傳進服務,接縫沒有漏接。
小觀察(不算漏洞):「捨棄」是寫入動作,守門卻用了讀取的標準,專案裡的「檢視者」「觀察者」也能丟掉稽核員還沒確認的工作單。影響只到同一個專案、而且只是軟刪一張暫存單,重新上傳就回來了。要不要收緊成跟上傳一樣(稽核員或管理者),交給首腦決定。
計畫本身不會:確認匯入動的計畫就是 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 匯入)也是同樣的寫法,不是這條匯入路線特有。
預覽會把解析結果、該計畫的稽核人員清單都回給前端。稽核人員清單本身在稽核計畫頁就是「專案成員都看得到」(list_ap_parties 的註解與行為都是這樣),沒有多給。沒有「清單所有工作單」這種方法,也就沒有「列出別人的工作單」這條路。
ap_docx_parse_job_repo_impl.py:36 的 update 只用內部 id 找、:50 的 deactivate 只用 uid 找,都沒有再加「屬於哪家公司」。但這兩支的每一個呼叫點,前面都已經過 _require_job,或者用的是剛剛才建立的工作單(上傳流程)。所以現在不構成「檢查甲、動乙」。將來如果有人新增呼叫點卻沒先驗歸屬,就會出事,而資料庫那一關又是壞的(第 6.1 節)。
公式注入要成立,得先有「把這段文字寫進 Excel」的出口。Word 解析出來的文字寫進的是稽核計畫的行程標題與說明。我在主專案找過,稽核計畫沒有匯出 Excel 的功能(assessment_plan_app_service.py 全檔沒有 xlsx 相關程式)。這些文字如果之後流進別的匯出(例如稽核報告),屬於那一棒的範圍。前端的匯入元件沒有用 v-html 直接渲染內容。
現況:已修(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/102/152/ 裡面含有 102,所以子公司看得到母公司的工作單;公司 2 也會被路徑 /12/ 誤判成符合。_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 行把這兩張表列為「有開隔離」,應該改掉。
| 表 | 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 行已經登記,本棒不重報。
「工具報的兩條存在嗎」:可信度高。兩次掃描共 4 個候選、12 票全數投出,4 條全部 3:0 成立,嚴重度都沒被調降。兩側的研究員彼此看不到對方,卻各自掃到壓縮炸彈,互相印證。比對規則那條我另外在本機實測過。
「第 5、6 節」:runner 自行開檔核對,未經三人面板投票。關鍵事實都實查過:
is_admin 的來源:開 jedi-iam 原始碼確認。帶旗標的帳號:DEV 實查(17:28)。cm_app 模擬五種身分實測(17:29)。「只有這些嗎」:不保證。
AssessmentPlanAppService 的四支守門與寫入方法)是我手動核對的。| 項目 | 套件側 | 主專案側 |
|---|---|---|
| 掃描範圍 | 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 節(三條規則)。密鑰專項沒有撿到任何東西。
RegistryBase。檢查要放在第一次打開檔案之前)。blsadmin(子公司 102)帶超級管理員旗標:確認是不是刻意的測試資料。這個旗標在程式很多地方被當成「平台管理員」使用,只要出現在子公司帳號上,每個讀它的地方都要重新想一次(第 5.1 節)。