範圍:套件側
jedi-oscal-v221 檔/1,503 行(SSP 服務層、深複製、凍結快照、metadata 複製、13 支 SSP 子表 repo、4 支共用 metadata repo)。 掃描工具:Claude Code 官方claude-securityplugin,effort low,scoped 掃描(指定檔案清單)。 基準 commit:eafc7ae511d5(monorepo 主 checkout,工作區有平行 session 的未提交改動,標 dirty)。 驗證章:verified,0 findings。只掃不修。
工具沒找到問題;人工把主專案 25 處呼叫端逐處開檔核對,也都沒有洞。卡片最擔心的「凍結的 SSP 被事後竄改」不成立。 每一處都先在同一份 SSP 底下找到那筆資料,才把編號交給套件;改資料時也都是「從資料庫讀出來、改幾個欄位、存回去」,沒有拿使用者送來的內容重新組一筆。凍結的 SSP 在主專案有擋:所有寫入 SSP 的網址都要通過 SspPermissionChecker.require_manager,其中那一步「計畫還能不能改」只要發現這份 SSP 是某一輪稽核的凍結快照,就一律拒絕。
本棒淨新增 1 條低(V1-1,資料完整性,不是越權)。 複製 SSP 時,有兩種「用編號指向同一份 SSP 裡另一筆資料」的欄位沒有跟著換成新編號,複製出來的 SSP 仍然指向來源 SSP 的資料:
party_uuid)DEV 實查:47 筆凍結快照的沿用授權,提供者全部指向別份 SSP 的人員。目前沒有任何程式拿這個編號去全表找人,所以不會讀到別家的資料;結果只是凍結的稽核基準裡,「提供者」這一欄對不上任何人。
SSP(系統安全計畫)是客戶最核心的合規文件:系統有哪些元件、資產、人員,每個控制項怎麼實作。這支套件負責把 SSP 存進資料庫、讀出來、複製一份(開專案時從資源庫範本複製),以及凍結成快照(開始稽核時把當下的 SSP 鎖成稽核基準)。
這支套件管的 SSP 相關 17 張表都沒有客戶欄位,也沒有資料庫隔離(DEV 以 cm_app 唯讀實查,2026-09-24 14:22:隔離開啟 0 張、隔離規則 0 條、客戶欄位 0 個)。換句話說,程式只要漏一步,就能直接讀寫到別家客戶的 SSP,資料庫不會再擋一次。
套件本身沒有網址入口,也不知道使用者是誰,所以它「不問資料屬於誰」是設計上的預期。但它把「只要給編號就能讀、改、刪」的方法開放給主專案用,檢查歸屬的責任就全落在呼叫端。這一棒要回答三件事:
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度 | 來源 |
|---|---|---|---|---|---|---|
| V1-1 | 複製 SSP 時,「提供者」和「元件的沿用授權編號」這兩種內部參照沒有換成新編號 | 凍結快照與新專案的 SSP 裡,沿用授權的「提供者」指向來源 SSP 的人員,本份 SSP 裡找不到這個人;元件的「沿用授權」同樣可能指錯地方。稽核基準裡這一欄對不上任何人 | 不用打,每次複製都會發生。要變成「讀到別家資料」,還需要將來有人寫出「拿人員編號全表找人」的程式,目前全庫沒有 | 套件 ssp_clone_service.py:170-176(複製沿用授權時要換 party_uuid)、:184-190(複製元件時要換 props 裡的 leveraged-authorization-uid);人員新編號在 metadata_clone_service.py:99-111 產生,要把「舊編號→新編號」對照表傳給 SSP 複製 |
低:影響的是資料正確性,不是越權。今天沒有任何路徑會因此讀到別份 SSP 的內容。不是「資訊」等級,是因為凍結快照是稽核證據,裡面的欄位錯了會影響稽核基準的可信度 | runner 開檔+DEV 唯讀實查,未經三人面板投票 |
工具的正式清單是空的(見第 4 節)。V1-1 是人工追卡片「複製時子物件有沒有帶錯家」這條時找到的,沒有經過三人審查小組投票。
工具派了 2 個研究員(1 個讀完整個範圍,1 個專門找密鑰),2 個都正常回報,都沒有提出候選。所以審查小組沒有東西可以投票,驗證章蓋 verified。整個過程沒有失敗。
研究員沒提 V1-1,合理的推測是:它只讀套件這 21 檔,看到的是「元件的 id 有換、人員的編號也有換」,但人員是另一支檔案(metadata 複製)換的,看不出兩支檔案之間的對照表沒有傳過去。這個問題要把兩支檔案放在一起看,還要查資料庫裡的實際資料才看得出來。
作法:每一處都開檔,從進入點往下追到套件呼叫,確認兩件事。①編號是不是在「同一份 SSP 的清單」裡比對找到的;②交給 update_* 的物件是不是讀出來的那筆。
| 呼叫處 | SSP 從哪來(權限) | 找父層的方式 | 改的物件 |
|---|---|---|---|
ssp_components_app_service.py:123,135 |
require_manager(ssp_uid) |
_resolve :138-142:列這份 SSP 的元件,比對 uid,找不到就 404 |
讀出來的 existing,只改 title/description/purpose/type/status/props |
ssp_resources_context_service.py:106,111 |
兩個入口:專案 ssp_resources_app_service.py:45-52(require_manager);資源庫 module_frame_ssp_resources_service.py:66-77(模組的 template_ssp_id) |
_fetch_item_in_scope :117-123,同上,另外限定 type |
讀出來的 item |
module_frame_components_service.py:115,125 |
_template_ssp_id(module_frame_uid) |
_resolve :128-132 |
讀出來的 existing |
ssp_inventory_items_app_service.py:121,130 |
require_manager |
_resolve :180-184 |
讀出來的 existing,只改 description/props/implemented_components |
module_frame_inventory_service.py:116,125 |
_template_ssp_id |
_resolve :128-132 |
同上 |
ssp_leveraged_app_service.py:125,134 |
require_manager |
_resolve(檔尾,列本 SSP 的沿用授權比對 uid) |
讀出來的 existing,只改 title/party_uuid/日期/remarks/props |
module_frame_leveraged_service.py:127,134 |
_template_ssp_id |
_resolve :137-141 |
同上 |
ssp_party_app_service.py:108,118 |
require_manager → _metadata_id |
_resolve(metadata_id, uid):列這份 SSP 的人員比對 |
讀出來的 existing,只改 email/props(姓名、類型鎖住不改) |
module_frame_party_service.py:132,141 |
_template_ssp_id → _metadata_id :144-157 |
_resolve :166-170 |
同上 |
ssp_control_implementation_service.py:90 |
require_manager |
_get_or_create_ir(ssp_id, control_id) :928-936:列這份 SSP 的控制項實作比對 |
讀出來的 ir,只改 props/remarks |
ssp_control_implementation_service.py:860,878 |
require_manager |
先 _get_or_create_ir,再 _statement_for(ir.id, …) :938-942(只在這筆控制項底下找) |
讀出來的 stmt |
module_frame_control_default_service.py:209,223 |
_resolve_template_ssp_id |
_ir_for_control :118-122 |
讀出來的 ir |
module_frame_control_objective_default_service.py:223,240 |
_resolve_template_ssp_id :78-83 |
:223 先 IR 再 statement(:138-167);:240 是把這份範本的所有控制項和敘述一層一層走過,比對 uid |
讀出來的 stmt |
補充說明:
update()(jedi_common base_repository_impl.py:406-427)會把物件上所有不是空值的欄位寫回資料庫,包括 ssp_id、system_implementation_id 這種「屬於哪份 SSP」的欄位。25 處都是讀出來再改,而且都沒有把使用者輸入整包塞進物件,所以這些歸屬欄位維持資料庫裡的原值,資料搬不了家。update_ssp、delete_implemented_requirement、delete_statement、update_by_component、delete_by_component,以及 6 支 get_*):我用 grep 搜了主專案(api/app/common/domain/infra)和 compliance-audit,確實都沒有人呼叫。打不到,可以併入死碼清理。套件層確實沒有保護:oscal_snapshot_service.py:87 只是把 status 設成 frozen,ssp_service 的 update_* 一支都不看 status。保護在主專案:
SspPermissionChecker.require_manager(common/authz/ssp.py:98-103)→ _require_ap_editable(:131-142)→ SspProjectResolver.is_writable(domain/oscal/service/ssp_project_resolver.py:111-133)。is_writable 的第一條規則:如果這份 SSP 是透過「稽核輪次的凍結快照」(project_audit_rounds.ssp_id)找到的,就一律回 False,拒絕寫入。就算是還在用的 SSP,也只有「目前這一輪還在規劃階段」才能改。:91-92),不會放行,也就是出狀況時預設拒絕。ssp_system_characteristic_app_service.py:54);程序書池(ssp_document_pool_service.py:64,118,155,163,184,192);控制項實作與程序書(ssp_control_implementation_service.py:79,845,865,883,889,907);Word/Excel 匯入到專案 SSP(ssp_docx_import_app_service.py:497→:581、ssp_excel_import_app_service.py:520→:782)。資源庫範本那一側走的是模組編號,凍結快照不會被當成範本。snapshot_ssp(audit_round_app_service.py:397,前面有「必須是 manager」和「狀態必須是 planning」兩道檢查),以及讀取(project_current_ssp_service.py:51)。它沒有任何修改 SSP 子表的呼叫。附帶提一個小地方(不列發現):同一支檔案裡另外兩個入口 require_write_access/require_read_access(common/authz/ssp.py:164-209)全庫零呼叫者。而且它們底下的 SspContextResolver._try_resolve_module_frame 讀的是 ssp.profile_id,但 SSP 物件上的欄位名叫 import_profile_id,所以「範本 SSP」那條分支永遠不會成立。現在沒人用,不影響任何東西,建議跟死碼一起清。
現況:已修(M11-38,FR-114 CM-2185,套件 commit 1d3a59c8;舊快照依裁定不動,1.21.0 出貨)
ssp_clone_service.py:103-245 逐張子表看:
包含關係都有換:每一張子表的「父層編號」(ssp_id、system_characteristics_id、system_implementation_id、control_implementation_id、implemented_requirement_id、statement_id)都換成新複製出來的那筆。by-component 的 component_id 也照對照表換掉了(:225、:242)。卡片①問的「by-component 有沒有留著指向舊 SSP 的元件」不成立。
只有一個邊角:component_id_map.get(bc.component_id, bc.component_id)。如果某筆 by-component 指向的元件不在這份 SSP 底下,就沿用舊的編號。前提是來源資料本身已經錯了,正常流程產生不出這種資料,只記錄不列發現。
沒有換的(V1-1):這些欄位不是資料庫的關聯欄,而是「寫成 OSCAL 編號字串、指向同一份 SSP 裡另一筆資料」:
party_uuid → 指向人員。人員在 metadata_clone_service.py:105 換了新編號,但沿用授權在 ssp_clone_service.py:171-176 是原樣照抄。leveraged-authorization-uid → 指向沿用授權。沿用授權在 :174 換了新編號,但元件在 :185-190 是 props 原樣照抄。implemented_components[].component-uuid → 指向元件。問題同上,但 DEV 目前 0 筆資料有填這個欄位。DEV 唯讀實查(cm_app,2026-09-24 14:2x,查完 ROLLBACK):
| 參照 | SSP 狀態 | 總數 | 指向同一份 SSP | 指向別份 SSP |
|---|---|---|---|---|
| 沿用授權 → 提供者 | 凍結 | 47 | 0 | 47 |
| 沿用授權 → 提供者 | 草稿(含專案的 SSP) | 541 | 25 | 515 |
| 元件 → 沿用授權 | 草稿 | 10 | 8 | 2 |
前端顯示這些欄位時,是拿「本份 SSP」的清單去對照(例如 build_leveraged_title_map module_frame_components_service.py:195-203),所以指錯的那些只會顯示成空白,不會帶出別份 SSP 的內容。我用 grep 搜了主專案,沒有任何程式拿 party_uuid 去全表找人,所以不會跨份讀取。定為低。
卡片②「來源 SSP 編號是誰給的」:只有兩個地方會觸發複製。一是 project_start_app_service.py:345 clone_resource_library,來源三件組由 _resolve_source_triple 從已發布的資源庫解析出來(FR-095 H1 掃過);二是 compliance-audit audit_round_app_service.py:397 snapshot_ssp(living_ssp_id),living_ssp_id 從專案擴充表取,不是使用者傳進來的(FR-116 C2a 範圍)。主專案沒有其他地方直接叫 deep_clone_ssp。
list_ssps 不帶條件就回全部 SSP:不成立,下次不用重查全庫有 5 處呼叫(盤點寫 6 處,實查少一處):ssp_party_app_service.py:128、module_frame_party_service.py:151、ssp_import_template_app_service.py:1063、export/ssp_v2_content_loader.py:85、compliance-audit project_current_ssp_service.py:51。全部都有帶 id=,而且這個 id 在前面都已經確認不是空的(權限檢查回來的 SSP、模組的 template_ssp_id 空值會先擋、living_ssp_id 空值會先擋、匯出兩個入口都先確認 id 存在)。沒有任何一處會拿空條件去查。
metadata_clone_service.py:99-111 會把人員整筆複製(含 email、電話),只換 id、metadata_id、uuid。兩個觸發點:專案成立時從資源庫範本複製(範本是同一家客戶自己建的,或是平台公版;公版本來就是要給客戶用的),以及凍結快照(同一個專案內複製)。目的地都是同一家客戶,而且凍結快照本來就該保存當下的聯絡資訊,才能當稽核基準。
17 支 repo 我逐支讀完。自己寫的方法全部都用父層編號過濾(get_by_ssp、get_by_system_implementation、get_by_system_characteristics、get_by_control_implementation、get_by_implemented_requirement、get_by_statement)。ssp_repo_impl.py 和 4 支 metadata repo 沒有自己寫任何方法,只有從基底繼承來的。繼承來的 get_by_id/update/delete_by_id 只看編號,但它們只有在服務層開放出去時才有意義,這部分 5.1 已經逐處追完。
21 檔全部讀過。ssp_service.py 剩下沒被點名的方法:upsert_system_characteristics :135-147(用 sc.ssp_id 找到既有那筆、帶入它的 id)、get_or_create_*、list_*,全部都用 SSP 編號限定範圍。oscal_snapshot_service.clone_resource_library :98-179:目錄和基準線的複製在 V4a 範圍,SSP 這一段跟 5.3 相同。除了 V1-1,沒有找到其他問題。
「這幾條存在嗎」:高。 V1-1 是開檔讀程式碼、再用 DEV 實際資料對過數字確認的(凍結快照 47/47 指錯)。5.1 和 5.2 的「不成立」,是 25 處呼叫加上所有寫入 SSP 的網址逐一追到權限檢查那一行得出的結論,不是抽查。
「只有這幾條嗎」:中。
cm_app 帳號查。「稽核輪次」這張表有資料庫隔離,查詢沒帶客戶身分就看不到任何一列(回 0 筆),所以「71 份凍結快照都接在某一輪上」這件事沒辦法用資料驗證,只驗證了程式邏輯(找不到輪次就預設拒絕,所以就算資料不齊也不會變成可以寫入)。import_ssp、clear_ssp_body)屬 V3 範圍,本棒只確認了匯入到專案 SSP 的那條路會經過凍結判斷。| 項目 | 數值 |
|---|---|
| 範圍 | 套件側 21 檔/1,503 行,effort low,scoped,focus attack-surface |
| 基準 commit | eafc7ae511d5(dirty:工作區有平行 session 的未提交改動) |
| 研究員 | 派 2(1 位讀全範圍、1 位專找密鑰),回 2,失敗 0 |
| 原始候選 | 0 |
| 審查小組投票 | 0(沒有候選可投) |
| 驗證輪次 | 1 輪,正常完成 |
| 耗時 | 約 4 分鐘(2 個 agent) |
| 驗證章 | verified,0 findings,reason_kind 空白(CLAUDE-SECURITY-REVISION-eafc7ae511d5-dirty.json) |
| 工具 run ID | wf_6fdd8b35-b85 |
| 工具原始報告 | 套件 repo jedi-oscal-v2/CLAUDE-SECURITY-20260924-061613/(未入版控) |
| 人工核對 | 主專案 25 處呼叫+5 處 list_ssps+所有寫入 SSP 的入口;DEV 唯讀查詢 4 組 |
clone_metadata 產生的「人員舊編號→新編號」和複製沿用授權時產生的「舊編號→新編號」兩張對照表傳給 SSP 複製,然後換掉 party_uuid、元件 props 的 leveraged-authorization-uid、資產清單的 component-uuid 三處。已經產生的 47 份凍結快照要不要修補資料,是另一個問題:修補等於改動稽核證據,本身就需要決策。ssp_service 的 11 支零呼叫方法,加上 SspPermissionChecker.require_write_access/require_read_access(零呼叫,而且範本分支因為欄位名寫錯,永遠不會成立),建議併入 FR-092。ssp_service.update_*,又沒有經過 SspPermissionChecker,就能修改稽核證據。要不要在套件層也加一道檢查(status == frozen 就拒絕寫入),請裁決。沿革見 LOG 與 git log。