FR-118 V3 掃描報告——OSCAL 整份文件匯入匯出

V3 掃描報告 — OSCAL 整份文件匯入匯出(CM-2126)

範圍:套件 jedi-oscal-v2 的 1 檔/1,660 行:jedi_oscal_v2/app/service/io/oscal_io_service.py。 掃描工具:Claude Code 官方 claude-security plugin,effort low,scoped 掃描。 基準 commit:套件 repo eafc7ae511d5(feature/review,工作區有平行 session 的未提交改動,stamp 標 dirty;範圍內這一支檔乾淨)。 驗證章:verified,0 條(研究員沒提出任何候選,面板沒有票可投)。只掃不修。


1. 一句話結論

這一棒沒有新的資安問題。 工具零發現;卡片點名的七件事,runner 逐條跨 repo 開檔追完,全部不成立,或是總表已登記的舊案。

  • 人員 uuid:查清了,目前沒有任何入口能讓使用者直接交一份 OSCAL 字典進來。Excel 那條 W2 沒追完的路,這次補追完:人員、元件、外部服務、設備、實作項目的 uuid 全部由伺服器產生。套件「給什麼 uuid 就存什麼」維持地雷、不是洞。
  • 資料量:寫入次數實測是「元件數 × 7」左右,一萬筆元件級的資料約十幾秒內寫完,碰不到 120 秒逾時;Excel 每張工作表又已有 5,000 列上限。不成立。
  • 蓋掉別份 SSP:套件本身範圍都對;真正的缺口是主專案「誰可以指定要蓋哪份範本」,就是總表第 123/125 項,不另計。

另外追出 1 條非資安的真 bug(V3-1):專案 SSP「再匯入一次」時,人員、實作項目的 uuid 每次都被換成新的,外部服務與設備每次都多一份重複。不會跨份讀寫,但會讓舊的內部參照指到不存在的人。


2. 這一棒在檢查什麼

這支檔是整個套件唯一的「整份進、整份出」:

Word/Excel 上傳 ─(W1/W2/Excel 解析)─→ 解析單 ─使用者按確認─→ import_adapter 組成 OSCAL 字典
    ─→ import_ssp(本檔)逐筆寫進 45 張沒有客戶隔離的表

資料庫裡的一份文件 ─→ export_oscal(本檔)─→ OSCAL JSON(給外部工具)

它是 45 張零隔離表的批次寫入口,所以要回答四件事:

  1. 進來的字典有多大、有沒有人管。
  2. 「更新模式」用什麼認定「這是同一筆」,能不能被拿去蓋掉別份 SSP 的資料。
  3. 匯出會不會把不屬於這份文件的東西組進去。
  4. 匯到一半失敗會不會留下一半。

背景:這 45 張表沒有客戶欄位、沒有資料庫隔離(總表第 128 項,盤點檔第 5 節;本棒 DEV 唯讀重查 oscal.system_security_plans、oscal.parties 兩張隔離仍為關,2026-09-24 18:58)。能不能跨客戶讀寫,百分之百看呼叫端有沒有先從有隔離的表往下找。


3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 嚴重度+為什麼 來源
V3-1 非資安真 bug:專案 SSP 再匯入時,uuid 每次換新、外部服務與設備每次重複 人員的 uuid 被換掉,原本指著它的「外部服務提供者」變成指向不存在的人(DEV 604 筆外部服務有 17 筆提供者查無此人);外部服務、設備清單每匯一次多一份 專案負責人對同一份專案 SSP 用 Word 或 Excel 再匯入一次(正常操作,不是攻擊) 套件 oscal_io_service.py:998-1000(人員)、:1292-1295(實作項目)、:1326-1329(實作敘述):更新時已配對到舊列就一律沿用舊 uuid,不看字典有沒有帶;:1183-1235 外部服務與設備改用自然鍵(標題/描述)配對,或由主專案在組字典前先套上現有 uuid 不是資安:不跨份、不跨客戶、沒有權限繞過;是資料正確性問題 runner 開檔追出、未經三人面板投票;DEV 唯讀佐證

工具報的:0 條。 研究員讀完整支檔、密鑰專項也跑完,兩者都沒有提出候選。


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

無。零發現不等於乾淨,下一節是 runner 照卡片逐條追的結果。


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

5.1 🔴 有沒有「使用者能直接給 OSCAL 字典」的入口:沒有(下次不用重查)

這一條依首腦補充改問:套件信任呼叫端給的 uuid(:982 uuid=p.get("uuid") or self._gen_uuid()),只有在使用者能把 uuid 放進字典時才是洞。追了全部呼叫端:

① import_ssp 只有兩個呼叫者,都在主專案。

  • app/oscal/service/ssp_docx_import_app_service.py:361、:385、:509(Word)。
  • app/oscal/service/ssp_excel_import_app_service.py:614(Excel)。
  • 其他 jedi 套件(含 compliance-audit)零呼叫。
  • 主專案沒有任何網址收 OSCAL JSON 匯入:唯一接到本檔的網址是框架版本的目錄下載(見 5.4),是匯出。

② 兩條路組字典的地方,uuid 全部是伺服器產生。

  • Word:W2 已查(docx_to_oscal_ssp.py:41/69/97)。
  • Excel(W2 沒追完的,這次補完):
    • excel_to_oscal_ssp.py:36(組織)、:51(個人)、:84(實作項目)、:98(實作敘述),全部 _common.gen_uuid()。
    • 共用層 _common.py:
      • 元件::281、:331;
      • 外部服務與它的提供者::401-404;
      • 設備::466、:497;
      • 實作說明掛點::579。
    • 全部是 gen_uuid()=uuid.uuid4()(:24-25)。

③ 使用者在確認時能改的東西,改不到人員 uuid。

Excel 確認時的 content_overrides 比 Word 寬:_apply_content_overrides(ssp_excel_import_app_service.py:694-695)對六張工作表的任一列可以蓋任意欄位(rows[idx][k] = v)。逐一看它能影響什麼:

使用者塞的欄位 到了組字典那一層 結論
uuid(任何一張表) 組字典時不讀這個鍵,一律 gen_uuid() 改不到
人員的 matched_user_id 存成人員的 matched-user-id 屬性(_common.py:121-123) 跟手動編輯人員(module_frame_party_service.py:317-319)是同一種能力;讀回時查 users 表,而 users 表 DEV 隔離為開,反查不到別家
設備的 implemented_components 塞成 [{"component-uuid": 任意值}] _resolve_implemented_components 原樣放行(_common.py:519-520) 跟手動編輯設備(ssp_inventory_items_app_service.py:91)同一種能力;讀回時只在本份 SSP 的元件清單裡對標題(module_frame_inventory_service.py:134-135),對不到就顯示空白,讀不到別份

④ 就算 uuid 重複,也沒有地方「用 uuid 全表找人」。

  • 主專案每一支「用 uuid 找人員/元件/外部服務/設備」的程式,都先列出本份 SSP 的清單再比對:
    • ssp_party_app_service.py:136-140;
    • module_frame_party_service.py:166-170;
    • ssp_components_app_service.py:140;
    • ssp_leveraged_app_service.py:139;
    • ssp_inventory_items_app_service.py:182;
    • 以及 module_frame 同名四支。
  • 全庫 grep 沒有 PartyQueryEntity(uuid=…) 這種全表查詢。
  • DEV 唯讀:oscal.parties 裡「同一個 uuid 出現在兩份以上 metadata」= 0 筆。

結論:地雷還在,但目前沒有引信。將來如果開放「上傳 OSCAL JSON」,要在組字典前把使用者給的 uuid 全部換掉,或在套件 import_ssp 一律自產。

5.2 陣列長度沒上限、逐筆寫入會不會卡住資料庫:不成立(下次不用重查)

套件確實不管大小:import_ssp 對每個陣列逐筆 add(),而共用底層 BaseRepositoryImpl.add 每筆都 flush()(jedi_common/.../base_repository_impl.py:401-403),等於每筆一次資料庫來回。所以做了三組實測:

① 寫入次數(假資料庫,只數次數):一份字典放 N 個人員、N 個元件、N 個設備、N 個實作項目(每項各一個實作敘述、兩個說明掛點)。

N 模式 寫入次數 讀取次數 純 Python 耗時
1,000 建立 7,003 4 0.01 秒
5,000 建立 35,003 4 0.06 秒
20,000 建立 140,003 4 0.34 秒
20,000 更新 140,003 60,009 6.5 秒(假資料庫每次回全部列,真資料庫只回同層幾筆,實際會更快)

寫入次數是線性的,沒有平方級。

② 單筆成本(DEV 本機,暫存表,最後 ROLLBACK):

  • 純來回:0.05 毫秒;
  • 逐筆 INSERT … RETURNING:0.07 毫秒;
  • 照套件的寫法「ORM add 再 flush」:0.11~0.12 毫秒,2 萬筆 2.3 秒。

③ 換算:14 萬筆寫入約 16 秒。資料庫在另一台機器、來回慢十倍,也在 120 秒逾時的量級附近,而且這還是 N=2 萬的極端值。

實際上 N 到不了 2 萬:

  • Excel:每張工作表讀到第 5,000 列就停(excel_parser/sheet_handlers.py:115-118 max_row_cap=5000),上傳限 10MB(ssp_excel_import_app_service.py:47)。
  • Word:一份能產出幾萬個控制項或元件的 Word,在解析階段就先被總表第 173 項那六個慢點卡到逾時了。

結論:大小的防線在主專案那一層(每表 5,000 列+解析逾時),套件這層沒有、也不需要另加。若將來開放 OSCAL JSON 匯入,要在那個入口自己限陣列長度。

5.3 clear_ssp_body 的 SSP 編號是誰給的:套件範圍對,缺口在主專案=總表第 123/125 項(既有案,不另計)

現況:既有案,不另計;V3-1 再匯入換新編號已修(M11-39,FR-114 CM-2185,套件 commit 1d3a59c8,1.21.0 出貨)

clear_ssp_body(:281-319)只刪這份 SSP 的三大塊,加上這份 metadata 底下的人員與角色:

  • get_by_ssp(ssp_id);
  • PartyQueryEntity(metadata_id=…);
  • RoleQueryEntity(metadata_id=…)。

子表靠外鍵連鎖刪除,不會刪到別份。

兩個呼叫端的編號來源:

  • Excel ssp_excel_import_app_service.py:464-478:job.source_uid → get_resource_library → ssp_template_id。
  • Word ssp_docx_import_app_service.py:334-337 → :384:job.source_uid or payload.get("source_uid") → 同上。

get_resource_library 只問「存在嗎」,不問「你能不能改」。資源庫表 DEV 隔離為開,但讀取規則放行原廠公版(scope='SYSTEM')與母公司分享(SHARED),所以讀得到的範圍包含不屬於你的範本。這就是:

  • 總表第 123 項(Excel,_verify_source_exists 與 _confirm_update_module_frame 無守門);
  • 第 125 項(Word,含「確認時再換一次目標編號」)。

本棒補一句「套件丟出來的長什麼樣」:套件照單全收呼叫端給的編號、先清再建,第二道防線不存在。修法照第 123/125 項,在主專案補守門即可,套件不用動。

5.4 匯出會不會組進不屬於這份文件的東西:不會;SSP/稽核結果/改善計畫三種「有能力、沒入口」

① 範圍都對。 四支 _export_* 都先用 uid 撈根文件,之後所有子物件都用根文件的數字編號往下撈:

  • 目錄::531-649,catalog.id;
  • SSP::652-670,ssp.id/ssp.metadata_id;
  • 稽核結果::1398-1417,ar.id → 每個 result 的 result.id → risk 的 risk.id;
  • 改善計畫::1557-1613,poam.id。

by-component 的 component-uuid 用本份 SSP 的元件對照表反查(:844-852),沒有全表查詢。

② 唯一有網址的是目錄下載:api/oscal/routes/framework/oscal_framework_version_route.py:118-143,短效憑證綁版本 uid,FR-113 O8b 已看過。

③ 另外三種沒有入口。 OscalExportAppService.export(app/oscal/service/oscal_export_app_service.py:29)能接四種文件,但主專案只有上面那一處呼叫它、固定傳 'catalog'。

它的檔頭註解寫「本期權限:jwt 認證即可;per-project 參與者授權為 follow-up」。將來若有人為 SSP/稽核結果/改善計畫加匯出網址,不能直接接這支——它不檢查文件屬於誰,而底下的表沒有隔離。記地雷,不另計。

④ build_ssp_snapshot(:257-278):同樣以 SSP 編號往下組,呼叫端的編號來源同 5.3。預覽時把目標範本現有內容回傳給使用者,這是第 125 項「動手前還能先讀」那一半,既有案。

5.5 交易:整份一個交易,中途失敗整份撤回(下次不用重查)

  • 套件沒有自己開交易:全套件 grep .commit() 零筆;本檔也沒有。
  • 兩個呼叫端的確認入口都有 @transaction:
    • Excel ssp_excel_import_app_service.py:302;
    • Word ssp_docx_import_app_service.py:303。
    • 兩支檔全檔零 .commit()、零 session_scope()。
  • clear_ssp_body → import_ssp → 還原程序書關聯三步在同一個交易裡。還原那支(ssp_document_pool_service)雖然也標了 @transaction,但共用底層在「已經在交易裡」時直接沿用、不另開(jedi_common/.../db.py:230-241)。
  • 「範本已經有內容」的檢查排在任何寫入之前(:393-401)。Word 那條接住這個錯誤、改走「清空再建」時,前面沒有半筆寫入要撤。

5.6 錯誤訊息:套件的內部編號沒有回到前端(下次不用重查)

套件一律 raise ValueError(f"... id={...!r}"),訊息帶內部數字編號,例如:

  • :389 target ssp not found: id=…;
  • :277、:534、:655。

追三個接收點:

接收點 怎麼處理 ValueError 前端看到什麼
ssp_excel_import_app_service.py:620-630 msg = str(e) 只用來判斷是不是「already has a body」,然後寫日誌 固定錯誤碼 GRC_IMPORT_SSP_TEMPLATE_NOT_EMPTY 或 GRC_EXCEL_INVALID_FILE,不帶原文
ssp_docx_import_app_service.py:363-366、:515-520 寫日誌 固定錯誤碼 GRC_DOCX_PARSE_FAILED
oscal_export_app_service.py:43-45 寫日誌(info) 固定錯誤碼 GRC_EXPORT_DOC_NOT_FOUND

卡片提到的 ssp_docx_import_app_service.py:220,226 寫進解析單並回前端,是解析階段的例外(第 132/174 項同形、O4 範圍),不是本檔丟的。

5.7 by_component 掛在哪個元件底下:只會掛到本次寫入的這份 SSP 的元件(下次不用重查)

_import_by_component(:1342-1395)用 comp_map.get(bc["component-uuid"]) 找元件編號,查不到就跳過、寫日誌。comp_map 只有三種來源,全部限定在本份 SSP 的系統實作 new_si.id 底下:

  • 本次寫入的元件(:1166);
  • 同一筆元件的來源 uuid(:1174-1175);
  • 更新模式下這份 SSP 原本的元件(:1179-1181,來自 get_by_system_implementation(new_si.id))。

別份 SSP 的元件 uuid 就算被塞進字典,也對不到,會被跳過。

5.8 整支檔通讀的其他觀察

  • 更新模式的自然鍵(:362-372):每一種都帶父層編號。

    • 人員、角色:metadata_id;
    • 元件、外部服務、設備:system_implementation_id;
    • 實作項目:control_implementation_id;
    • 敘述:implemented_requirement_id;
    • 資訊類型:system_characteristics_id。

    「更新」時先用這些鍵在本份底下撈既有列,再把 entity.id 設成撈到的那筆。不會跨份配對。

  • 共用底層的 update 只看 id 找列(base_repository_impl.py:413-417,entity.uid 在這些實體上不存在時走 id)。本檔設進去的 id 全部來自上一行的「本份底下撈到的列」,沒有從字典讀 id。字典裡就算塞了 "id": 123 也不會被讀(本檔所有建構實體的地方都沒有 .get("id"),角色的 r.get("id") 是 OSCAL 的角色代號、存成 role_id)。

  • 所有 JSON 欄位原樣存(props、links、remarks、responsible-parties 等):沒有深度或長度檢查。來源是主專案組好的字典,巢狀深度由組字典的程式決定、使用者控制不了結構;長度受 Excel 每格內容與 10MB 上限約束。匯出時原樣吐回,公式注入的問題在匯出 Excel 那端(第 126/148 項),不在 JSON 匯出。

  • _poam_owned_query(:1621-1645)用類別名稱字串判斷要建哪種查詢條件:寫法脆弱(改名就壞),但三個呼叫都傳固定的 repo,不是輸入。不算問題。


6. V3-1 詳述(非資安真 bug)

情境:專案負責人在「專案 SSP」頁把同一份 Word 或 Excel 再匯入一次(source_type='ssp',Model C,更新模式)。

發生什麼:組字典的那一層每次都產生全新的 uuid(5.1 ②)。套件更新模式配對到舊列之後,對 uuid 的處理不一致:

物件 配對用的鍵 配對到舊列時 uuid 怎麼處理 結果
元件 標題+類型 一律沿用舊的(:1157-1162,註解寫明是為了讓參照不斷) ✅ 穩定
人員 類型+名稱 字典有帶就換成新的(:999-1000 只在字典沒帶時沿用) ❌ 每次換新
實作項目、實作敘述、說明掛點、資訊類型 控制項代號/敘述代號/元件/標題 同人員(:1294、:1328、:1391、:1092) ❌ 每次換新
外部服務、設備 uuid 本身(:1186、:1213) 新字典的 uuid 永遠對不到 ❌ 每次多一份

後果:

  • 提供者對不到人:外部服務的「提供者」存的是人員 uuid(party_uuid,軟參照、沒有外鍵)。人員 uuid 被換掉後,舊的外部服務指向不存在的人。DEV 唯讀(2026-09-24 19:0x):604 筆外部服務有 17 筆提供者在整張人員表查無此 uuid。
    • 這個數字混著 V1-1(複製 SSP 時沒換參照)的效果,不能全算在這條上;
    • 但 V1-1 的指錯是「指到別份 SSP 的人」(人還在),查無此人更像是本條的形狀。
  • 清單重複:DEV 設備有 5 組、外部服務有 2 組「同一份 SSP 底下描述/標題相同」的重複列。DEV 目前沒有任何一筆完成的專案 SSP 匯入(兩張解析單表 source_type='ssp' 的 completed 都是 0),所以這些重複的來源無法證實是這條路。本條以讀程式碼推論為主,DEV 無法直接重現。
  • 不影響資源庫範本:範本的再匯入走「先清空再建立」(5.3),不走更新模式。

為什麼不是資安:全部發生在同一份 SSP 裡,沒有跨份讀寫、沒有權限繞過;觸發的是有權限的負責人做正常操作。

修法方向:

  • 套件更新模式「配對到舊列就沿用舊 uuid」一律比照元件的寫法。
  • 外部服務與設備改用自然鍵配對(外部服務:標題;設備:描述),跟其他物件一致。
  • 或主專案在組字典前先套上現有 uuid。

建議修在套件,一處修好、Word 與 Excel 兩條都受益。


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

「這幾條存在嗎」:高。

  • 零發現是工具讀完整支檔的結果。
  • 七條卡片疑點每條都有檔名行號可查;5.2 有實測數字。
  • V3-1 是開檔推論,加上 DEV 唯讀的間接佐證,DEV 無法直接重現,這點已在第 6 節講明。

「只有這幾條嗎」:中偏高。

  • 本檔 1,660 行 runner 逐行通讀完。
  • 呼叫端跨 repo 追到主專案兩支匯入服務的確認路徑、兩個組字典的轉接器、決策合併、匯出服務與唯一網址,以及其他 jedi 套件(零呼叫)。
  • 打折處:
    • 5.2 的實測是「假資料庫數次數+DEV 本機量單筆成本」再乘起來,沒有用真的 14 萬筆去打真的匯入流程。本機資料庫與 Python 在同一台,來回比落地版(同機 docker)快或相當,換算值可能偏樂觀一個量級。
    • import_diff/ 目錄沒有逐支通讀(FR-113 O9 範圍),只讀了跟 uuid 與決策合併有關的部分。

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

項目 值
run ID wf_94fad4ad-8c6
報告目錄 套件 repo CLAUDE-SECURITY-20260924-105441/(不入版控)
stamp CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json
verification.status verified(reason_kind 無;候選 0,面板 0 票)
形狀 low:1 名研究員讀 1 檔+1 次密鑰專項(focus=attack-surface)
候選 → 通過 0 → 0
agent 數/失敗 2/0(journal.jsonl 無 failed,沒撞看門狗)
耗時 掃描約 2 分 20 秒;人工追查與實測另計
人工實測 假資料庫數寫入次數(N=1k/5k/20k,建立與更新);DEV 本機暫存表量單筆成本(5,000 次來回、3.5 萬筆 INSERT、2 萬筆 ORM add+flush,全部 ROLLBACK)
DEV 唯讀實查 2026-09-24 18:58~19:08:oscal 兩表與資源庫表隔離狀態、module_frames 規則原文、人員 uuid 跨 metadata 0 筆、外部服務提供者查無 17/604、設備與外部服務同份重複 5/2 組、兩張解析單表完成的專案 SSP 匯入 0 筆;全程 BEGIN READ ONLY … ROLLBACK

9. 待首腦裁決

  1. V3-1 要不要登總表 §3.2(非資安真 bug):
    • 跟 V1-1(第 36 項,複製 SSP 沒換參照)是同一族——都是「用 uuid 字串互指的欄位,uuid 一換就斷」。
    • 建議同一張修正卡,修法都落在套件。
    • DEV 無法直接重現(沒有完成的專案 SSP 再匯入),需要修的人或首腦用一份 Word 在 DEV 跑兩次確認。
  2. 「有能力、沒入口」兩個地雷要不要記進 §3.4:
    • ① 套件 import_ssp 信任字典裡的 uuid 與陣列長度;
    • ② OscalExportAppService.export 對 SSP/稽核結果/改善計畫不檢查文件歸屬。
    • 兩者現在都打不到,但將來開放「上傳 OSCAL JSON」或「匯出 SSP JSON」時會直接變成洞。建議記一句「開這兩種入口前必須先補守門」。
  3. FR-118 八棒全收:V3 是最後一棒。本棒無資安新增,第 123/125 項(主專案匯入覆蓋範本無守門)仍是這條匯入鏈上唯一的真缺口。