掃描設定檔管理(Scan Profile Management)¶
事實基準:2026-08-04 從 FE
src/views/detection-profile/DetectionProfileManageView.vue+components/六支(ProfileFormDialog/ProfileNewVersionDialog/ProfileVersionTable/ProfileDetailDialog/ProfileCopyDialog/ProfileSourceDialog)+profileDisplay.js+DetectionProfileControlsView.vue+components/ControlExtractionSummary.vue+composables/useProfileTaxonomy.js/useProfileUsage.js、BEapi/detection_tools/routes/detection_profile_route.py/detection_profile_taxonomy_route.py+app/detection_tools/service/detection_profile_service.py/detection_profile_taxonomy_service.py/detection_profile_extraction_service.py全鏈 + DEVconfig.detection_profiles/detection_profile_versions/detection_profile_controls/detection_profile_taxonomies實查掃出(FR-060 檢測基準內容管理;前身 FR-059)
變更紀錄
| 日期 | FR | 內容 |
|---|---|---|
| 2026-08-04 | FR-060.2 | 檢測基準可就地修正來源、可硬刪(此前只能新增不能改也不能刪——URL 打錯只能再開一版,錯的那版永遠留在版本歷史)。判準是客觀的「有沒有被使用過」而非詢問使用者意圖:從未被使用過→ 可就地改來源(PATCH .../source,版號不動、改完重抽控制項)與硬刪(DELETE);已被使用過→ 一律凍結,只能建立新版本。🔴 不提供任何「確定要更新嗎」的強制選項、無 force 參數可繞過——版本 uid 是派工契約、sha256 是 agent 對帳基準,同一版本 uid 在不同時間指向不同內容,之後回頭看掃描報告沒人能確定當時掃了什麼;這是稽核可信度問題不是 UX 取捨,故不交給使用者當次決定。「被使用過」的判定範圍=三個引用來源(任務已綁定 / 已產生派工單 / 已有執行歷史),任一階段出現過即算。🔴 版本層穿透軟刪專案(引用只計入活著的專案,原則是「擋下的理由必須是使用者看得見的」)、主檔層不穿透(破壞半徑不同),因此「版本層可刪、主檔層不可刪」是合法狀態不是矛盾。UI 上「修正來源」與「刪除」接進操作選單,刪除與停用互斥只出現一個(CM-1077.1/.2/.3=CM-1082/1083/1084) |
| 2026-08-04 | FR-060.3 | url 型基準的來源過期偵測:開控制項明細頁時對來源發一次輕量 HEAD,拿 ETag / Last-Modified 與抽取當下記下的值比對,判斷已抽取的快照是否仍與來源一致——三種提示狀態「來源未變」(不提示、不觸發任何抽取)/「來源已過期」(提示+「重新解析」按鈕,能壓狀態時另自動排背景重抽)/「無法判定」(遠端未回驗證標頭或探測失敗,一律當未變更)。🔴 判太鬆比判太嚴更糟——判成有變更會讓那類來源每次開頁都重跑一次 CINC。從檔加 source_etag / source_last_modified 兩欄(CM-1075) |
| 2026-08-04 | FR-060.2 | 主列表分頁預設 10 → 20 筆(每頁筆數可選 10 / 20 / 50),與專案既有列表頁慣例對齊;只改初始值,換頁與筆數選項行為不動 |
| 2026-08-04 | FR-060.2 | 主列表操作欄由 7 顆圖示按鈕收成單一 kebab 選單(欄寬 13rem → 5rem,選單內以分隔線分讀取類 / 寫入類兩段、停用紅字)。🔴 連帶改變權限的表達方式:原本 can_edit === false 時編輯 / 版更 / 複製到其他工具 / 停用整組不渲染,改為保留在選單內但 disabled——整列唯讀的原因(公版唯讀 / 已停用)改在選單頂端說明一次(不逐項後綴),個別動作缺 capability 才在該項 label 後綴「(無權限)」。理由:收進選單後若照舊整組消失,使用者只會看到一個少了幾項的選單、無從得知為什麼;而「這一列語意上根本沒有的動作」(自有列的「複製為自有」、無現行版的「版更」與「檢視控制項」)仍然不放——「沒有這個動作」與「不能做這個動作」是兩回事。「複製為自有」不受整列唯讀影響(它寫的是新的租戶列、不動公版,公版唯讀時它正是那句說明要人走的出路)。公版唯讀的鎖 icon 從操作欄移到「來源」欄的公用版 Tag 旁(唯讀是這一列的性質,不是某顆按鈕的狀態)(CM-1078) |
| 2026-08-04 | FR-060.2 | 主列表與控制項清單頁都加「全部展開/全部收合」(位置皆在展開欄的表頭,圖示隨狀態在 pi-angle-double-right / pi-angle-double-down 切換)——逐列點開才看得到附屬內容,要橫向比對「哪幾支抽取完成 / 誰還停在 pending」時很費事。管理頁作用於當前頁的列且刻意不 invalidate 版本快取(展開 N 列=N 個子元件掛載,逐一失效就是打 N 個請求,而快取最舊只到「上次載入列表」那一刻、與主列表欄位的新鮮度同級);控制項頁只作用於當前篩選結果而非全部 234~708 條。同時做展開列的視覺降階(凹陷底色 + 左側 accent 直線 + 內縮 + 標題列 + 弱化子表表頭)——子表與主表共用同一套 DataTable 樣式,展開後讀起來像「主表下面又接了一張主表」;樣式抽成四個全域 class 放 _theme-overrides.scss 供兩頁共用。順手修兩個既有小瑕疵:抽取狀態 Tag 少 align-items-start 被 flex-column 拉滿欄寬(看起來像進度條)、版本欄 7rem 在亮色主題讓「v1 + 現行版」折兩行 → 8rem(CM-1080) |
| 2026-08-03 | FR-060 | 資料模型單表拆三張表(主檔 config.detection_profiles + 版本從檔 detection_profile_versions + 控制項 detection_profile_controls),版本從檔逐列沿用舊 uid 使 profile:<uid> 契約零轉換;主列表語意改為一列=一支基準(版本歷史走展開列)。三項新功能:① 分類三軸(標的類型 / 標的產品 / 基準體系,受控兩軸的值域存 config.detection_profile_taxonomies 由 root 維護、顯示名稱由前端 i18n 翻譯);② 基本資料編輯 PUT /detection-tool-profiles/<uid>(改名不升版,消費 FR-059 預留的 detection-profile.update 能力點);③ 控制項抽取與瀏覽(BE 主機裝 CINC Auditor 非同步解析,新增控制項清單頁)。platform_hint 隨舊表退役;舊表 rename 為 detection_tool_profiles_deprecated_20260803 觀察期後另案 DROP |
| 2026-08-03 | — | 修正「停用後無法以同名再建」(CM-1047):租戶版唯一索引 uq_dtp_tool_tenant_name_version 補 partial 條件 WHERE is_active,讓停用列不再佔用名稱(與另外兩組索引及停用的設計意圖對齊);同時新建 / fork / 複製的起始版號由固定 1 改為「同名 is_active 列續號」,避免曾版更過的設定檔停用後再建撞到仍佔號的舊版。⚠️ 此列描述的是拆表前的單表模型,當日稍後的 FR-060 拆表已使三組索引與續號規則整套退場,只保留「停用=讓出名字」的語意(見 §12 坑 17) |
| 2026-08-01 | FR-059 | 新頁建立:檢測工具掃描設定檔(profile / content)後台管理——公用版(SYSTEM,平台維護全租戶可見)+租戶自有(TENANT)雙軌同頁;上傳(zip / tar / tar.gz / tgz 四格式、50MB 上限、結構與安全驗證)或登記外部連結雙軌來源;版更(INSERT 新版不覆蓋)、fork 公版為自有、複製到另一工具池、停用(軟刪)。任務抽屜的 profile 下拉由本庫動態餵(options_source 宣告,見 任務配置) |
1. 功能描述¶
掃描設定檔管理是檢測工具(CINC Auditor / GCB)掃描設定檔(profile / content,本頁亦稱「檢測基準」)的後台管理頁:把「一支掃描要套用哪套檢測基準」的內容檔收進平台庫,取代原本「進版控 → 人工投放到 Agent 主機 → 改資料庫選項」的維運鏈。上傳或登記後,任務配置頁的 profile 下拉立即列出庫內項目(不經任何部署動作),Agent 執行時透過平台通道取檔——封閉網路(Agent 無外網)環境也能使用檔案型設定檔。上傳成功後平台另在背景解析內容,把「這支基準到底會驗什麼」拆成一條條控制項供瀏覽(§6.5)。
資料模型自 FR-060 起是主檔 + 版本從檔 + 控制項三張表:config.detection_profiles 一列=一支基準(名稱 / 描述 / 分類三軸 / 所屬工具 / 啟用旗標),config.detection_profile_versions 一列=一版(來源檔 / sha256 / 版號 / 抽取狀態),config.detection_profile_controls 一條控制項一列。主列表因此改為一列=一支基準,版本歷史走展開列。拆表解掉的是舊模型「拿 name 當身分」造成的痛點——舊唯一索引是 (detection_tool_id, tenant_id, name),名稱參與識別,於是改名改不動、唯一的改名途徑是 fork 複製一份;拆表後改名只是主檔一列 UPDATE,所有版本天然共用同一個名稱(§6.4 編輯)。
資料歸屬仍是公用版 / 租戶自有雙軌(與流程範本管理同一模式):scope='SYSTEM' 公用版由平台(root 租戶)管理員維護、全租戶可見可用但唯讀;scope='TENANT' 自有版各租戶自行上傳維護、他租戶不可見。非 root 使用者對公用版可「複製為自有」(fork)後自行編輯。設定檔按 detection_tool_id 分池:上傳時必選所屬工具,GCB 任務抽屜只見 gcb 池、CINC Auditor 只見 inspec 池;同一份內容兩工具都要用,走「複製到其他工具」各登記一筆。
來源型態雙軌:檔案型(source_type='file')上傳打包檔存平台儲存,Agent 走 mTLS 通道拉檔+本機 cache;連結型(source_type='url')只登記 URL 字串,掃描時 Agent 原樣交給檢測引擎自行下載,平台不驗證可達性、不代管憑證(打不打得通是使用端網路與授權問題,設計界線非缺陷)。⚠️ 抽取(解析控制項)是另一回事——連結型的抽取由平台代為下載到記憶體解析,不落儲存、不改 source_type,掃描路徑一行不動(§6.5 與 §12 坑 12)。
主要使用者:持有 detection-profile.* capability 的角色(角色權限矩陣「掃描設定檔管理」列;比照流程範本管理授予各租戶系統管理員角色);tenant 管理員與 root 用同一頁,能力差異由 BE 回的 can_edit 與三層守門決定。分類字典(§6.6)的維護則限 root。
角色速覽:
| 角色 | 一句話 |
|---|---|
租戶系統管理員(持 detection-profile.create / update / delete) |
上傳 / 編輯基本資料 / 版更 / 停用自有基準、瀏覽控制項;對公用版唯讀但可 fork 成自有副本 |
| 平台管理員(root 租戶) | 同上,另可維護公用版(建立 / 編輯 / 版更 / 複製 / 停用 SYSTEM 列)與分類字典(新增 / 排序 / 停用 / 刪除分類 key) |
| 一般使用者 | 不進本頁;在任務抽屜下拉選用庫內基準(menu API 人人可讀,分類字典讀取端亦然) |
1.1 功能總覽(本頁全部功能)¶
| # | 功能 | 說明 | 位置 | 詳述 |
|---|---|---|---|---|
| 1 | 基準列表(分頁) | 一列=一支基準(非一版);公用版+本租戶自有合併列出、公用版排前面;欄位:展開鈕 / 名稱(含描述兩行截斷)/ 來源(公用版・自有 Tag,公版唯讀時 Tag 旁掛鎖 icon)/ 所屬工具 / 標的類型 / 標的產品 / 基準體系 / 當前版本(v{n},可為 —)/ 狀態(兩態)/ 更新時間・更新者 / 操作(單一 kebab) |
主畫面 DataTable(lazy 分頁) | §6.2 |
| 2 | 版本歷史展開列 | 點展開鈕才打 GET /<uid>/versions(收合過的結果留在前端快取);子表欄位:版號(當前版標記)/ 來源類型 / 內容(檔名+sha256 或 URL)/ 抽取狀態徽章(含控制項數)/ 更新時間・更新者 / 「檢視控制項」鈕(逐版,CM-1078 未動它——主列表選單那項只看得到現行版)。展開區塊有標題列+筆數並做視覺降階(見 §5) |
主列表展開列 | §6.4 |
| 2.1 | 全部展開/全部收合 | 一鍵攤開當前頁全部列的版本歷史(再按一次收合),圖示隨狀態切換;⚠️ 刻意不 invalidate 版本快取(見 §5) | 展開欄的表頭 | §5 |
| 3 | 篩選:來源範圍 | 全部 / 公用版 / 自有 segmented pill | 篩選列 | §6.2 |
| 4 | 篩選:所屬工具 | 下拉(來源=工具目錄 GET /detection-tools) |
篩選列 | §6.2 |
| 5 | 篩選:標的類型 / 基準體系 | 兩個下拉,選項來自分類字典;各多兩個特別項——「全部」與「未分類」,未分類走前端過濾(見 §12 坑 5) | 篩選列 | §6.6 |
| 6 | 搜尋名稱 | 關鍵字(300ms debounce,後端 name like 過濾) |
篩選列 | §6.2 |
| 7 | 顯示已停用基準 | InputSwitch——打開後送 is_active: null(連停用的一起回)。⚠️ 拆表後不再有 current_only,那是版本的概念 |
篩選列 | §4 |
| 8 | 新增 / 登記 | Dialog:選所屬工具(必選)→ 選來源類型(切換下方欄位)→ 檔案型上傳打包檔 / 連結型填 URL;名稱 / 描述 / 分類三軸;root 另可選 scope(建公用版) | 「新增設定檔」按鈕 → Dialog | UC-DPM-01 |
| 9 | 編輯基本資料 | 同一個 Dialog 的編輯模式:改名稱 / 描述 / 分類三軸,不升版、版本鏈不動;不收來源欄位(換來源請走版更) | 操作選單「編輯基本資料」→ Dialog | UC-DPM-05 |
| 10 | 版更 | 從檔 INSERT 新版+舊版退出現行、主檔不動;檔案型換新檔、連結型換新連結;只改描述也算一次版更 | 操作選單「版更」→ Dialog | UC-DPM-02 |
| 11 | 複製為自有(fork) | 公用版複製成本租戶可編輯副本;僅公用版且啟用中的列有此項;🔴 不受整列唯讀影響(寫的是新租戶列、不動公版,公版唯讀時它正是出路) | 操作選單「複製為自有」→ Dialog | UC-DPM-03 |
| 12 | 複製到其他工具 | 同內容在另一工具池各登記一筆(分池語意);目標工具下拉排除來源工具本身 | 操作選單「複製到其他工具」→ Dialog | §6.4 |
| 12.1 | 修正來源 | 就地改某一版的內容來源(打錯網址 / 指錯檔案 / 選錯型態的補救):版號不動、版本鏈不變,改完抽取狀態回落 pending 並自動排重抽。🔴 只有從未被使用過的版本可做(用過即凍結);主列表改的是現行版,要改歷史版走版本子表 |
操作選單「修正來源」→ Dialog(主列表現行版)/版本子表 kebab 同名項(指定版) | UC-DPM-06 |
| 13 | 停用(軟刪) | confirm 後主檔 is_active=FALSE(拆表後不再連帶降 is_current);任務下拉即不出現,歷史執行紀錄參照可回溯。🔴 與「刪除」互斥只出現一個(見 13.1) |
操作選單「停用」(紅字)→ confirm | UC-DPM-04 |
| 13.1 | 刪除(硬刪) | 🔴 硬刪不是軟刪:刪主檔連帶刪掉底下所有版本與控制項(DB CASCADE),當作沒存在過。只有從未被使用過才有這一項(已用過的列出現的是「停用」)。confirm 框列出「將一併刪除 N 個版本、M 條控制項」的實際數字。版本子表另可刪單一版(當前版不可單獨刪) | 操作選單「刪除」(紅字)→ confirm / 版本子表「刪除此版本」 | UC-DPM-07 |
| 14 | 詳細資料 | 唯讀 Dialog:名稱 / 來源 / 工具 / 分類三軸 / 當前版的類型・檔名+SHA-256 或 URL / 版號 / 描述 / 建立者・時間 | 點名稱,或操作選單「檢視」 | §6.2 |
| 15 | 控制項清單頁 | 獨立頁(/plugin/detection-profile-manage/versions/:versionUid/controls):摘要三數字(控制項總數 / 人工待判 / 自動檢查)+抽取狀態+控制項表格(編號 / 標題 / 檢查方式 / 嚴重度 / 來源檔),前端搜尋・篩選・分頁+全部展開/收合(只作用於當前篩選結果,見 §5) |
操作選單「檢視控制項」(看現行版)或版本子表同名鈕(看指定版)→ 換頁 | §6.5 |
| 16 | 手動重抽 | 控制項頁摘要區的「重新解析」鈕;覆寫該版控制項,對 create 能力。出現時機兩種:抽取失敗 / 尚未抽取的補救途徑,以及 url 型偵測到來源已過期時(見 16.1)。⚠️ 一般 succeeded 且未過期的狀態刻意不給這顆——對著沒變的來源重跑 CINC 是純浪費 |
控制項頁摘要區 | §6.5 |
| 16.1 | url 型來源過期偵測 | 開控制項明細頁時對來源發一次輕量 HEAD 比對 ETag / Last-Modified,判斷這份快照是否仍與來源一致。三態:來源未變(不提示、不重抽,載入耗時與 file 型同級)/來源已過期(提示「來源網址的內容已經更新,以下顯示的是先前的快照」+「重新解析」鈕;能壓狀態時另自動排背景重抽並改講「來源有更新,正在重新解析」)/無法判定(探測失敗或來源不給驗證器 → 表達與「未變」相同)。file 型不做(有 sha256 當信任根) |
控制項頁摘要區的提示條 | §6.5 |
| 17 | 列操作選單(kebab) | 操作欄只有一顆三點鈕(tooltip「操作選單」),點開後以分隔線分兩段:讀取類(檢視 / 檢視控制項)與寫入類(編輯基本資料 / 版更 / 修正來源 / 複製為自有 / 複製到其他工具 / 停用或刪除)。每項都有文字標籤不必靠 tooltip 猜。版本子表的操作欄同樣是 kebab(檢視控制項 / 修正來源 / 刪除此版本——三顆並排會擠爆 4rem 欄寬,且兩張表形狀一致使用者不必學兩套) | 列最右「操作」欄 + 版本子表操作欄 | §5 / §4 |
| 18 | 公版唯讀提示 | 公用版列對非 root:① 來源欄的「公用版」Tag 旁掛鎖 icon+tooltip「公用版由平台管理員維護,您可以『複製為自有』後編輯」;② 操作選單頂端一行說明同一句話,選單內受影響的寫入項 disabled(不逐項後綴)。已停用的列同機制、說明改為「已停用的設定檔不可再異動」 | 來源欄 + 操作選單頂端 | §3 / §4 |
| 19 | 使用中凍結提示 | 已被使用過的基準 / 版本:「修正來源」disabled、「刪除」整項不放(改出現「停用」),原因以選單頂端說明列講一次並帶得出擋住它的專案名稱(「此設定檔已被 N 個專案的掃描任務使用(專案 A、專案 B)…請改用『停用』」)。四種原因同時成立時顯示最具體的那一個(使用中凍結 > 已停用 > 公版唯讀 > 缺權限) | 操作選單頂端(主列表與版本子表各一份) | §4 / §6.4 |
| 20 | 分頁 | 每頁筆數 預設 20(可選 10 / 20 / 50),lazy 分頁走 BE | DataTable 底部 | §6.2 |
UC(§2)展開具後端寫入的核心路徑:新增 / 登記(8,UC-DPM-01)、版更(10,UC-DPM-02)、fork(11,UC-DPM-03)、停用(13,UC-DPM-04)、編輯基本資料(9,UC-DPM-05)、修正來源(12.1,UC-DPM-06)、刪除(13.1,UC-DPM-07)。複製到其他工具(12)與 fork 同構(INSERT 新列),於 §6.4 說明;控制項抽取與瀏覽(15、16、16.1)於 §6.5、分類字典(5)於 §6.6 說明;純顯示 / 篩選 / 操作入口類(1~7、14、17~20)於 §5 / §6.2 說明。
⚠️ 分類字典本身的維護介面(新增 / 排序 / 停用 / 刪除分類 key)目前沒有前端頁面——四支 API 與前端 service 方法都已就位,但沒有任何 view 消費寫入端(實查
grep -rn "createTaxonomy\|updateTaxonomy\|deleteTaxonomy" src/views/零命中)。現況新增分類值走 migration;要補 UI 時 BE 是現成的。見 §12 坑 2。
2. Use Case¶
角色速覽:見 §1。
流程圖視覺慣例:菱形 = 判斷、橘底 = 例外/擋下、紅框 = 錯誤(標 error code)、綠框 = 成功終點、虛線 = 可選路徑。
UC-DPM-01 上傳 / 登記掃描設定檔¶
| 項目 | 內容 |
|---|---|
| 角色 | 持 detection-profile.create 的角色 |
| 前置條件 | 所屬工具存在於工具目錄(上傳時必選,P7 分池);帶了分類值時該值須是存在且未停用的字典 key |
| 產出 / 後置條件 | config.detection_profiles 新增主檔一列 + config.detection_profile_versions 新增 version=1, is_current=TRUE 一列,主檔 current_version_id 指過去;檔案型另落 public.upload_files 一筆實體+回填 sha256;HTTP 立即回應、內容抽取排進背景(§6.5);任務抽屜 menu 立即可選 |
流程:按「新增設定檔」開 Dialog → 選所屬工具(必選)+來源類型+名稱+分類三軸(皆選填)→ 檔案型選擇打包檔(FE accept=".zip,.tar,.tar.gz,.tgz"、maxFileSize=52428800)走 multipart 送出;連結型填 URL 走 JSON 送出(同一 endpoint 依 content-type 分流,_payload())→ BE capability 檢查 → 同工具同租戶同名檢查(DETECTION_TOOLS_409006)→ 分類值字典檢核(未知或已停用 → DETECTION_TOOLS_400015)→ 檔案型跑驗證管線(四格式偵測 / 50MB / 安全 / 結構,四種失敗各自獨立 error code,見 §6.3)→ 計算 sha256 → 存平台儲存 → INSERT 主檔+從檔 v1 → 交易 commit 之後才排背景抽取。連結型只驗 URL 形狀(須 http(s):// 開頭,否則 DETECTION_TOOLS_400012),不驗可達性。
root 建立公用版(
scope='SYSTEM')已有 FE 入口(FR-060 D18):新增 Dialog 在!isEdit && isPlatformAdmin時多渲染一個 scope 選擇欄(ProfileFormDialog.vue:387-401),並在選 SYSTEM 時顯示提示文案——FR-059 預留卻未使用的field_scope/hint_scope_system兩個 i18n key 因此活起來。編輯模式不給改 scope(公版轉自有請用「複製為自有」)。
UC-DPM-02 版更¶
| 項目 | 內容 |
|---|---|
| 角色 | 持 detection-profile.create 的角色;SYSTEM 列限平台管理員 |
| 前置條件 | 該列 can_edit=true 且 is_active=true(canWriteProfile,見 §4;不成立時選單項仍在但 disabled),且已有當前版(沒有當前版就沒有來源類型可沿用,選單項整項不放) |
| 產出 / 後置條件 | 從檔 INSERT version+1 新列+舊版 is_current=FALSE(主檔一列不動,名稱天然共用);主檔 current_version_id 改指新版;menu / 任務下拉即指新版;舊版留表供歷史執行紀錄回溯;新版排背景抽取 |
流程:按「版更」開 Dialog(顯示「為『{name}』建立新版本(目前 v{n})」)→ 檔案型換新檔 / 連結型換新連結(名稱與所屬工具不在此改——那是主檔的屬性,走「編輯」UC-DPM-05)→ BE guard(SYSTEM 列非平台管理員 → DETECTION_TOOLS_403001)→ 帶了新來源就重跑驗證管線;未帶新來源(只改描述)也算一次版更,沿用舊版的檔案與 sha256,讓每一列自身資訊完整 → 先降舊版 is_current 再 INSERT——順序不可顛倒,partial unique index uq_dpv_profile_current((profile_id) WHERE is_current)在 DB 層擋兩筆 current,這是被索引強制的不是靠開發者自律。
UC-DPM-03 複製為自有(fork 公用版)¶
| 項目 | 內容 |
|---|---|
| 角色 | 持 detection-profile.create 的角色(不需平台管理員——fork 寫的是新租戶列,不是改公用版) |
| 前置條件 | 來源列為公用版且啟用中(FE scope==='SYSTEM' && is_active 才放這個選單項;公版唯讀不影響它可按,見 §4) |
| 產出 / 後置條件 | 本租戶新增主檔一列 scope='TENANT', tenant_id=本租戶 + 從檔 version=1 的可編輯副本(分類三軸一併沿用);共用來源版的檔案實體(file_id / sha256 沿用,不複製 binary);副本若尚未抽取過則排一次抽取 |
流程:按「複製為自有」→ Dialog 預填來源名稱(可改)→ 送出 → BE 檢查本租戶同工具同名(DETECTION_TOOLS_409006)→ INSERT——scope / tenant_id 由 BE 硬寫成 TENANT / 本租戶,不沿用來源列(沿用會讓副本仍是公用版,等於任何人 fork 一次就多一份全站可見資料;detection_profile_service.py::fork_profile:413-429)。
UC-DPM-04 停用¶
| 項目 | 內容 |
|---|---|
| 角色 | 持 detection-profile.delete 的角色;SYSTEM 列限平台管理員 |
| 前置條件 | 該列 canWriteProfile=true |
| 產出 / 後置條件 | 主檔 is_active=FALSE(軟刪)。🔴 拆表後只降主檔這一個旗標——current_version_id 與從檔的 is_current 都保留,日後重新啟用不必重建版本鏈;任務下拉即不出現;歷史執行紀錄參照仍可回溯,已綁定舊參照的任務再派工會被擋(見 §11 派工展開) |
流程:按「停用」→ confirm(「停用後任務下拉不再出現此設定檔,既有的執行紀錄不受影響」)→ PUT /<uid>/deactivate → guard → 更新 is_active。
拆表前還要一併降
is_current,那純粹是為了不讓停用列擋住同名新版撞唯一索引(舊索引拿name當身分);拆表後唯一索引收斂成(profile_id) WHERE is_current,兩件事徹底解耦,那行程式碼整個消失(deactivate_profile:458-473)。
UC-DPM-05 編輯基本資料(改名 / 分類,不升版)¶
| 項目 | 內容 |
|---|---|
| 角色 | 持 detection-profile.update 的角色;SYSTEM 列限平台管理員 |
| 前置條件 | 該列 canWriteProfile=true(can_edit 且 is_active);帶了分類值時該值須是存在且未停用的字典 key |
| 產出 / 後置條件 | 主檔一列 UPDATE——name / description / target_type / target_product / benchmark_family;版本鏈完全不動(不新增從檔列、版號不變、current_version_id 不變、抽取不重跑) |
流程:按「編輯」開 Dialog(與新增同一個元件的編輯模式,預填現值、不渲染來源欄位與 scope 欄位)→ 送出 JSON → @require_capability("detection-profile.update") → service 層 _guard_system_writable(資源域守門,要先 resolve 出這支基準的 scope 才知道判誰)→ 改名時做同工具同租戶同名檢查(改成自己現在的名字不算撞名,故先比對再查;撞名 → DETECTION_TOOLS_409006)→ 分類值字典檢核(DETECTION_TOOLS_400015)→ UPDATE。
三個欄位的空值語意不同,這決定前端該怎麼送:
| 欄位 | 沒帶(null) |
空字串 |
|---|---|---|
name |
不改 | 400 DETECTION_TOOLS_400016(基準不能沒有名字) |
description / 三軸 |
不改 | 清空(正規化成 NULL,回到「未分類」)——分類填錯總得有辦法清掉 |
🔴 不收
source_type/url/file:換來源請走/new-version。混進來會讓「編輯不升版」這個保證失效——同一支端點既可能改名也可能悄悄換掉內容,使用者無從得知自己剛才做的是哪一種。出處:route
detection_profile_route.py::DetectionProfileDetailRoute.put:214-232、servicedetection_profile_service.py::update_profile:263-345。
UC-DPM-06 修正來源(就地改內容,版號不動)¶
| 項目 | 內容 |
|---|---|
| 角色 | 持 detection-profile.create 的角色(與版更、重抽同一組權限——它們產生的都是「這一版的內容」;update 對的是主檔的名稱/分類);SYSTEM 列限平台管理員 |
| 前置條件 | 該列 canWriteProfile=true,且 這一版從未被任何掃描任務使用過(§4 使用判定)。用過即凍結,無 force 參數可繞過 |
| 產出 / 後置條件 | 從檔一列 UPDATE source_type / file_id / url / sha256 四欄單次寫定;🔴 version 與 is_current 不動(改錯字不是版更);抽取衍生欄(supports / control_count / pending_count / source_etag / source_last_modified)一併歸零、extraction_status 回落 pending,舊控制項清掉,由 route 在交易 commit 後排背景重抽 |
與版更的差別是本功能最容易混淆的一點:版更=「這支基準有了新內容」(INSERT 新版、版號 +1、舊版留歷史);修正來源=「我上次打錯了」(原地改同一版、版號不變)。使用者打錯 URL 時的意圖是後者,而此前系統只提供前者,於是版本歷史裡永久留著一個「從來不能用、也永遠不會被用」的版本,日後看歷史的人分不出那是舊標準還是當初手殘。
流程:操作選單選「修正來源」→ Dialog(主列表帶現行版、版本子表帶該列指定版;url 型預填現值,多數修正是「這串網址改一個字」)→ PATCH /detection-tool-profile-versions/<版本uid>/source → capability → _guard_system_writable(資源域)→ 使用判定守門(用過 → 409 DETECTION_TOOLS_409009,訊息帶擋住它的專案名)→ 檔案型重跑驗證管線 → replace_version_source() 四欄同次 flush → commit 後排重抽。
🔴
source_type必填且允許與原值不同:「選錯型態」(該貼連結卻上傳了檔案)正是最常見的修正情境,沿用原值會讓它表達不出來。故來源類型是真正可改的欄位,不是唯讀顯示。為什麼是新的一支
replace_source()而非拼既有update()+clear_fields():chk_dpv_source_exclusive是行級 CHECK(file 型必有file_id且url IS NULL,反之亦然),而BaseRepositoryImpl.update()會跳過值為 None 的欄位——file→url 走update()只會寫上 url、留著舊file_id,flush 當下就撞約束。四欄必須在同一次 flush 寫定。
UC-DPM-07 刪除(硬刪,未被使用過才可做)¶
| 項目 | 內容 |
|---|---|
| 角色 | 持 detection-profile.delete 的角色;SYSTEM 列限平台管理員 |
| 前置條件 | 從未被使用過(主檔層判定=底下任一版被用過即算,不穿透軟刪專案);刪單一版另有兩道守門:版本層判定(穿透軟刪專案)+不可是當前版 |
| 產出 / 後置條件 | 🔴 硬刪——DELETE /detection-tool-profiles/<uid> 主檔消失,底下所有版本與控制項由兩層 DB CASCADE 一併帶走;DELETE /detection-tool-profile-versions/<uid> 刪一版、該版控制項 CASCADE 帶走。無任何軟刪標記、不可復原 |
與既有「停用」並存而非取代,語意不同:
| 動作 | 條件 | 語意 |
|---|---|---|
| 停用 | 隨時可用 | 不再出現在下拉,歷史仍可回溯,名字讓出來(主檔 partial 唯一索引帶 WHERE is_active,見 §12 坑 17) |
| 刪除 | 從未被使用過 | 完全消失,當作沒存在過 |
FE 上兩者互斥只出現一個(未使用 → 刪除;已使用 → 停用)——兩顆同時擺會讓人不知道該按哪個,條件互斥就讓系統判斷;判定尚未回來時走停用(隨時可用且非破壞性,是安全的預設)。刻意不是「兩項都放、刪除灰掉」。
流程:操作選單選「刪除」→ confirm(列出「將一併刪除 N 個版本、M 條控制項」的實際數字——使用者要看得到破壞半徑才能做決定;數字走版本子表的同一份快取算,拉不到時降級成不帶數字的句子、不編一個數字出來)→ DELETE → capability → _guard_system_writable → 主檔層使用判定(用過 → 409 DETECTION_TOOLS_409010)→ 硬刪。
刪單一版的兩道守門順序有意義:先擋「被使用過」(409009)再擋「是當前版」(409011)——被用過的版本無論是不是 current 都刪不得,先回答那個更根本的理由,使用者才不會白跑一趟去改 current 指標。
🔴 當前版不可單獨刪(
DETECTION_TOOLS_409011):current_version_id的 FK 雖是ON DELETE SET NULL,那是防資料損毀的最後一道、不是業務規則——指標懸空後派工就拿不到可綁的版本。要刪 current 請先把當前版改指其他版,或整支主檔刪掉。FE 不預先隱藏現行版的刪除項:它可以合法地被連同整支主檔一起刪,且 BE 錯誤訊息已明確引導,預先隱藏反而讓使用者不知道為什麼沒有這個選項。
3. 權限矩陣¶
| 操作 | FE 判定 | BE 強制 | BE 檢查位置 |
|---|---|---|---|
| 進入本頁 | ui_routes web-menu 可見性(detection-profile.read=ALL;寫入三能力=ANY) |
N/A(僅 JWT) | migration scripts/sql/2026-08-01-fr059-3-detection-profile-menu-route.sql |
| 列表 / 下拉 menu / 詳細 / 版本歷史 | 無 | 僅 @jwt_required()(無 capability 守門,menu 供任務抽屜全員取用;可見範圍由查詢收斂+RLS 決定,見 §12 坑 4) |
api/detection_tools/routes/detection_profile_route.py::DetectionProfileListRoute / MenuRoute / DetailRoute.get / VersionsRoute |
| 控制項清單 / 抽取狀態(讀) | 無 | 僅 @jwt_required()(與其他讀取端一致) |
DetectionProfileVersionControlsRoute.get / DetectionProfileVersionExtractionRoute.get |
| 新增 / 版更 / fork / 複製到其他工具 / 手動重抽 / 修正來源 | hasCap('detection-profile.create')——無權時:頁首「新增設定檔」鈕 disabled+tooltip「無權限」,列操作選單內對應項 disabled+label 後綴「(無權限)」 |
route 層 @require_capability("detection-profile.create")(POST 家族全對 create——版更 / fork / 複製都是 INSERT 新列;重抽與修正來源都會覆寫該版控制項,與版更同一組權限語意「產生這一版的內容」) |
detection_profile_route.py:47+各 route decorator |
| 使用判定(讀) | 開操作選單前查(走模組層快取,同列反覆開合只打一次) | 僅 @jwt_required()——它回的是「這支能不能改/能不能刪」的事實,本身不構成任何寫入 |
DetectionProfileUsageRoute.get / DetectionProfileVersionUsageRoute.get |
| 編輯基本資料 | hasCap('detection-profile.update') |
route 層 @require_capability("detection-profile.update")——🆕 FR-060 起這顆能力點才有實際消費者(FR-059 seed 了卻沒有端點在用,見 §12 坑 2 的歷史說明) |
detection_profile_route.py:52 / DetectionProfileDetailRoute.put |
| 停用 / 刪除(硬刪) | hasCap('detection-profile.delete')——兩者共用同一顆能力點且互斥只出現一個(依使用判定) |
route 層 @require_capability("detection-profile.delete")(停用是軟刪非真刪,但「讓它從下拉消失」的權限語意等同刪除;硬刪走同一顆是因為破壞性不低於停用、再細分一顆能力點只會讓角色矩陣更難懂)+service 層使用判定守門(409009 / 409010 / 409011) |
detection_profile_route.py:48 / DeactivateRoute / DetectionProfileDetailRoute.delete / DetectionProfileVersionDetailRoute.delete |
| 對公用版(SYSTEM)寫入 | 依 BE 回的 can_edit——false 時操作選單內的寫入項 disabled(不是隱藏,見 §4)、來源欄 Tag 旁顯示鎖 icon(FE 不自己重算「我是不是 root」,避免兩邊判定漂移) |
service 層 guard _guard_system_writable():scope=='SYSTEM' and not viewer_is_platform_admin() → 403 DETECTION_TOOLS_403001(新增 SYSTEM / 編輯 / 版更 / 複製到其他工具 / 停用適用;fork 不走 guard——寫的是新租戶列) |
app/detection_tools/service/detection_profile_service.py::_guard_system_writable:639;root 判定 canonical=common/authz/platform.py::viewer_is_platform_admin() |
| 分類字典讀取 | 無(下拉與篩選都要用) | 僅 @jwt_required() |
detection_profile_taxonomy_route.py::DetectionProfileTaxonomyListRoute.get / RefCountRoute |
| 分類字典寫入(新增 / 排序停用 / 刪除) | 尚無 UI(見 §12 坑 2) | route 層 @require_platform_admin_route——主體域守門(只看「你是誰」,與哪一列資源無關),依 FR-048 P1 允許 route decorator 形式 |
detection_profile_taxonomy_route.py POST / PUT / DELETE |
| 跨租戶隔離 | 無(列表本來就查不到他租戶) | repo 查詢顯式收斂 or_(scope=='SYSTEM', tenant_id==ctx.tenant_id)+DB RLS 主從各四段共八段兜底(SELECT 公用版人人可讀;INSERT / UPDATE WITH CHECK 堵租戶偽造 / 竊升 SYSTEM;DELETE 非 super 不能刪 SYSTEM) |
infra/detection_tools/repository/detection_profile_repo_impl.py;RLS scripts/sql/2026-08-03-fr060-1-detection-profile-split.sql:394-468 |
三層防禦分工(capability 管「能不能碰這個功能」、service guard 管「能不能碰公用版」、RLS 兜底):四個
detection-profile.*capability 全部is_platform=false——租戶管理員必須能建自己的 TENANT 基準與 fork 公用版,這正是雙軌功能的核心;公用版保護不在 capability 層鎖。與檢測工具管理(純平台目錄頁)的守門模型刻意不同。兩種守門軸刻意分開放:基準的寫入是資源域(要先 resolve 出那一列的
scope才知道判誰)→ 留在 app service 層;分類字典的寫入是主體域(root 才能改,與哪一列無關)→ route 層 decorator。兩者不可對齊成同一套。
4. 狀態機與前置條件¶
本頁無輪次 / 階段狀態機。拆表後主檔與版本各有自己的狀態,不可混談:
① 主檔狀態(主列表狀態欄,兩態)——is_active 單一旗標:
| 狀態 | 條件 | Tag | 語意 |
|---|---|---|---|
| 啟用中 | is_active |
success | 任務下拉可選(且須有當前版)、可編輯 / 版更 / 停用 |
| 已停用 | !is_active |
secondary | 軟刪——下拉不出現、再派工被擋、不可再異動 |
⚠️ 舊版 spec 的第三態「舊版」已隨拆表移進版本子表——那是版本的狀態(
is_current=false),主檔一列不再代表某一版。從檔第一期沒有版本級停用欄(現況無「停某一版留其他版」的需求,語意設計成本遠高於欄位成本)。
② 版本抽取狀態(版本子表與控制項頁,四態)——extraction_status:
| 狀態 | Tag | 語意 |
|---|---|---|
pending |
secondary | 尚未抽取——「還沒觸發過」不是失敗。⚠️ 空狀態文案不可寫成「抽取失敗」或「這份基準沒有內容」 |
running |
info | 解析中(實測 234 條約 28 秒、708 條約 117 秒),前端 5 秒輪詢 |
succeeded |
success | 三個數字(總數 / 人工待判 / 自動檢查)已可看 |
failed |
danger | 顯示 extraction_error 完整原文(截斷會讓使用者無法判斷是自己的檔壞了還是平台壞了) |
值域以 DB 的
chk_dpv_extraction_status為準(是succeeded不是done)。未知狀態走兩層降級:severity 退secondary、文案顯示原始字串(不顯示 i18n key、不留空白)——profileDisplay.js::extractionBadge。
③ 使用判定(CM-1077,決定可不可以改可不可以刪)——不是欄位而是查出來的事實(GET .../referencing-usage):
| 判定 | 可做的事 |
|---|---|
| 從未被使用過 | 就地修正來源(版號不動、改完重抽控制項)/刪除(硬刪) |
| 已被使用過 | 🔴 一律凍結——只能建立新版本;主檔層另可走「停用」 |
🔴 不提供任何「確定要更新嗎」的強制選項、無 force / confirm / override 參數可繞過。理由:版本 uid 是派工契約、sha256 是 agent 對帳基準,同一個版本 uid 在不同時間指向不同內容,之後回頭看某次掃描報告就沒有人能確定當時掃了什麼——這是稽核可信度問題,不是 UX 取捨,故不能交給使用者當次決定(測試有一條專門釘住三支方法的簽章不得出現這些參數)。
「被使用過」的判定範圍=三個引用來源,任一階段出現過即算:
| # | 來源 | 語意 |
|---|---|---|
| ① | config.job_execution_detection_tools.tool_params->>'profile' |
任務已綁定但還沒跑 |
| ② | compliance.agent_tasks.params->'_profile'->>'uid' |
已產生派工單 |
| ③ | compliance.detection_executions → agent_task_uid → ② |
已有執行歷史 |
⚠️ ②③ 必須加
source_type='url'條件:_profilepayload 的 uid 對 file 型存的是upload_files的 uid,只有 url 型才是版本列自己的 uid(§12 坑 10 的三種 uid)。不加這條會拿版本 uid 比檔案 uid,永遠比不中——判定漏一個來源就是靜默放行刪除。
兩層嚴格度刻意不同(版本層 / 主檔層共用同一個回應形狀但語意不同):
| 層 | 軟刪專案的引用 | 為什麼 |
|---|---|---|
| 版本層 | 🔴 穿透(不計入 in_use) |
專案刪除是軟刪、且沒有任何角色看得到已刪除專案,照字面判定會讓使用者被一個他看不到、無從查證也無法處理的專案擋住。原則是「擋下的理由必須是使用者看得見的」。判準只認 project_extensions.deleted_at,不可用 projects.status='archived'(兩套不同步的真相) |
| 主檔層 | ⚠️ 不穿透(計入 in_use,但仍不列進 projects 清單) |
刪主檔會連帶刪掉所有版本與控制項,而任務可能已刪、報告與證據還在,那時這支基準仍是稽核軌跡的一部分。寧嚴勿寬 |
🔴 因此存在「版本層可刪、主檔層不可刪」的合法狀態——這不是矛盾,是兩種刪除的破壞半徑不同。日後看到請勿當成 bug。兩層的
projects清單皆只列活著的專案:秀一個使用者查不到的專案名比不給理由更糟;也因此會出現in_use=true但projects為空的合法狀態(引用全在已軟刪專案裡),組訊息不可假設非空(畫面上不能出現空括號)。
頁面 gate 表:
| 條件 | 效果 | 出處 |
|---|---|---|
row.can_edit === false(公用版 × 非平台管理員) |
🔴 編輯 / 版更 / 複製到其他工具 / 停用仍留在操作選單內、但 disabled(CM-1078 起不再整組隱藏——收進選單後若照舊消失,使用者只會看到一個少了幾項的選單、無從得知為什麼);原因講一次放在選單頂端說明列(Menu 的 #start slot,文案=tooltip_system_readonly),不逐項後綴;來源欄的公用版 Tag 旁另掛鎖 icon+同一句 tooltip。「複製為自有」不受影響(見下一列) |
判定 profileDisplay.js::canWriteProfile:43;選單組裝 DetectionProfileManageView.vue::buildActionMenuItems:345、頂端說明 :427 與 template :737-740、鎖 icon :626-635 |
row.is_active === false |
同上機制(選單項 disabled + 頂端說明),說明文案改為 tooltip_inactive_readonly「已停用的設定檔不可再異動」 |
canWriteProfile:43、rowReadonlyReason:334 |
| 🔴 「複製為自有」與整列唯讀的關係 | 不 disabled——它寫的是新的租戶列、不動公版,公版對非平台管理員唯讀時,這正是那句說明要使用者走的出路;只受 detection-profile.create capability 管 |
DetectionProfileManageView.vue:391-400(註解明寫「絕不能跟著 disabled」) |
| — | 🔴 拆表後不再看 is_current:那是版本的屬性,主檔上根本沒有這個欄位,舊程式碼多判一個 is_current !== false 會讓每一列都被判成不可編輯 |
profileDisplay.js:38-42 註解 |
row.current_version === null |
「檢視控制項」與「版更」兩個選單項整項不放(沒有當前版就沒有控制項可看、沒有來源類型可沿用——那是「這一列語意上沒有這個動作」,與「不能做」的 disabled 是兩回事);版本欄顯示 —。整支停用過的殘留主檔會是這個形狀,列表必須渲染得出來 |
DetectionProfileManageView.vue:359 / :377 |
scope==='SYSTEM' && is_active |
才放「複製為自有」選單項(自有列 fork 自己沒有意義;停用列不該再被複製出去)——同樣是「不放」而非 disabled | DetectionProfileManageView.vue:391 |
!hasCap('detection-profile.create') / update / delete |
對應選單項 disabled+label 後綴「(無權限)」(項目仍放,與上述「不放」是兩層不同 gate)。⚠️ 整列唯讀時不重複後綴——頂端說明已經講過,逐項再標會讓選單變成五行重複長文 | capLabel:342、buildActionMenuItems:345-421 |
使用判定 in_use === true(已被使用過) |
🔴 「修正來源」disabled(動作存在、只是這一列不能做,拿掉會讓使用者不知道有這條路)+ 「刪除」整項不放、改出現「停用」(兩者互斥只出現一個)。原因由選單頂端說明列講,且帶得出擋住它的專案名稱 | useProfileUsage.js::usageReason、buildActionMenuItems(主列表)/ ProfileVersionTable.vue::actionMenuItems(版本子表) |
使用判定尚未回來 / 查不到(usage === null 或 unknown) |
走安全預設:給「停用」、「修正來源」disabled。🔴 判定失敗時保守回「無法判定」而不是「未使用」——判定回錯方向會放行不該放行的刪除 | useProfileUsage.js::UNKNOWN(in_use: true, unknown: true) |
| 多個「不能做」的原因同時成立 | 選單頂端只講最具體的那一個,優先序:使用中凍結 > 已停用 > 公版唯讀 > 缺 capability。「此版已被 A 專案使用」比「公版唯讀」更能讓使用者知道下一步,且它的出路(建新版本)與其他三種完全不同 | mostSpecificReason / actionMenuReadonlyReason |
版本子表的某列 is_current === true |
「刪除此版本」不預先隱藏——它可以合法地被連同整支主檔一起刪,BE 擋 409 DETECTION_TOOLS_409011 並明確引導(先切換現行版或整支刪掉);預先隱藏反而讓使用者不知道為什麼沒有這個選項 |
ProfileVersionTable.vue::handleDeleteVersion |
url 型 + succeeded + 有記錄過驗證器 |
開控制項明細頁時發一次 HEAD 比對;比出不同 → source_outdated=true。file 型、尚未成功抽過、沒有記錄過驗證器 → 一律不探測 |
detection_profile_extraction_service.py::_check_source_freshness |
source_outdated === true + 狀態已轉 pending / running |
提示「來源有更新,正在重新解析…」(與一般解析中分開講——使用者沒按任何按鈕卻看到轉圈,不講明原因會以為是卡住的殘留狀態) | ControlExtractionSummary.vue::isRefreshingOutdated |
source_outdated === true + 狀態仍 succeeded(壓不動狀態) |
靜態提示「來源已更新,以下是舊快照」+「重新解析」鈕,整條提示切警示色與 exclamation-triangle 圖示。🔴 不可改成轉圈——那個圈永遠不會完成 |
ControlExtractionSummary.vue::isStaleSnapshot |
form.source_type |
切換新增 Dialog 下半欄位:file → FileUpload(accept=".zip,.tar,.tar.gz,.tgz"、maxFileSize=52428800);url → URL 輸入框 |
ProfileFormDialog.vue |
!isEdit && isPlatformAdmin |
才渲染 scope 選擇欄(root 建公版,D18);編輯模式一律不渲染(公版轉自有請用「複製為自有」) | ProfileFormDialog.vue:387-401 |
編輯模式(isEdit) |
不渲染來源類型 / 檔案 / URL 欄位——編輯只改基本資料,換來源走版更 | ProfileFormDialog.vue |
| 「顯示已停用基準」switch 打開 | 列表 filters 送 is_active: null(BE 預設 true)。⚠️ 拆表後沒有 current_only 要一起放了 |
DetectionProfileManageView.vue::fetchProfiles:155-160 |
| 分類篩選選到「未分類」sentinel | 不送給 BE,改在前端過濾——代價是分頁筆數與 BE 回的 meta.total 不一致,列表下方顯示提示(見 §12 坑 5) |
DetectionProfileManageView.vue::displayProfiles:123-131 |
| 版更 Dialog | 沿用當前版的 source_type 分流欄位(檔案型換檔 / 連結型換連結),名稱與工具不在此改 |
ProfileNewVersionDialog.vue |
| 「全部展開」按鈕(兩頁的展開欄表頭) | 當前可展開的列數為 0 時 disabled;已全部展開時圖示切成 pi-angle-double-down、動作改為全部收合。作用範圍:管理頁=當前頁的列(displayProfiles)、控制項頁=當前篩選結果(filteredControls) |
DetectionProfileManageView.vue::allExpanded:243 / toggleExpandAll:247;DetectionProfileControlsView.vue::allExpanded:377 / :381 |
extraction_status ∈ {pending, running} |
控制項頁開 5 秒輪詢(只打最小狀態端點);轉 succeeded 才重載一次完整清單;離開頁面 / 狀態收斂 / 元件卸載都停掉計時器 |
DetectionProfileControlsView.vue 檔頭「輪詢」段 |
5. UI 設計¶
版面骨架(篩選列 + DataTable + 四組 Dialog):
⚠️ 此 wireframe 畫的是 FR-059 當時的版面(一列=一版、三態狀態欄、無分類欄與展開列),FR-060 拆表後與現況有落差,待重繪。CM-1078 / CM-1080 又讓它更過時一階:操作欄的語意已從「一排圖示按鈕」變成單一 kebab 選單(欄寬 13rem → 5rem),展開欄的表頭多了一顆「全部展開/收合」鈕;CM-1077.3 再加一階——選單多了「修正來源」,末項會依使用判定在「停用」與「刪除」之間切換,版本子表的操作欄也收成 kebab。以 §1.1 功能總覽與 §4 gate 表為準。
📸 實機截圖待補:主列表(公用版排前、分類三欄、展開列展開後的版本子表含抽取狀態徽章)、kebab 選單樣式(可寫入的完整選單 / 公版唯讀時頂端說明+多數項 disabled / 未使用時末項是「刪除」 / 使用中凍結時末項是「停用」+頂端帶專案名的說明 四張)、修正來源 Dialog、刪除確認框(含「N 個版本、M 條控制項」)、url 型過期提示三態(未變藍條 / 過期橘條+重新解析鈕 / 自動重解析中)、展開列降階後樣式(暗色與亮色各一,亮色是加了暖色遮罩的那版)、新增 Dialog(file / url 兩型態各一張、root 的 scope 欄)、編輯 Dialog、版更 Dialog、複製為自有 Dialog、詳細資料 Dialog(sha256 等寬字)、控制項清單頁(
succeeded三數字摘要 + 表格、pending空狀態、failed紅框)、任務抽屜 profile 下拉按分類分組。
狀態呈現:來源欄 Tag——公用版(info)/ 自有(success),公版對非平台管理員時 Tag 旁掛一個鎖 icon(CM-1078 從操作欄移來:唯讀是「這一列的性質」不是「某顆按鈕的狀態」,掛在來源標籤旁讀得出因果「公版 → 所以唯讀」,操作全進選單後也不會無處可掛);分類三欄——標的類型與基準體系走字典翻譯(查無 i18n 條目時顯示原始 key,見 §6.6),標的產品是自由文字直接顯示,任一軸為 NULL 顯示 —;當前版本欄 v{n}(無當前版顯示 —);狀態欄兩態 Tag(見 §4)。描述截兩行顯示於名稱下方。版本歷史走展開列:點展開才打 API,收合過的結果留在前端快取(重新載入列表時整批失效)。
列操作是單一 kebab(CM-1078):操作欄從 7 顆圖示收成一顆三點鈕(欄寬 13rem → 5rem),選單內以分隔線分兩段——上段讀取類(檢視 / 檢視控制項)、下段寫入類(編輯基本資料 / 版更 / 複製為自有 / 複製到其他工具 / 停用,停用走 menu-item-danger 紅字)。演進過程留了一步中間態:一開始把「檢視 / 檢視控制項」兩顆留在選單外,但兩顆圖示並排讀起來仍像一條工具列(眼睛與清單圖示語意相近容易誤認),於是全部進選單、只留一個入口——每一項都有文字標籤,不必靠 tooltip 猜。「能不能做」的表達規則見 §4 gate 表(disabled 而非隱藏 + 頂端說明一次)。
選單內容依「有沒有被使用過」而變(CM-1077.3):開選單前先查使用判定(走模組層快取,同列反覆開合只打一次),不 await——先開選單、判定回來後選單 model 自然重算,避免網路慢時選單延遲彈出;在此之前的短暫狀態走安全預設(給停用、修正來源灰掉)。判定影響兩處:①「修正來源」凍結時 disabled;②「刪除」與「停用」互斥只出現一個(未使用給刪除、已使用給停用),刻意不是「兩項都放、刪除灰掉」——兩顆同時擺會讓人不知道該按哪個。原因放在選單頂端說明列而非逐項 tooltip:PrimeVue 的 disabled menu item 有 pointer-events: none,tooltip 掛在項目上根本觸發不了。訊息三要素缺一不可——被幾個專案用 + 被哪些專案用 + 該怎麼辦(只寫「已被使用不可修改」不合格);⚠️ 那個數字是專案數不是掃描任務數(判定端點回的是 projects 與 version_refs,沒有任務層級計數,硬湊一個任務數會是編造的)。
刪除的確認框要列出破壞半徑:主檔「將一併刪除 N 個版本、M 條控制項,且無法復原」、版本「將一併刪除該版的 M 條控制項」——只寫「確定刪除嗎」使用者無從判斷後果,版本子表裡每列長得很像更是無從確認自己點對了。數字走版本子表的同一份快取算(使用者多半是展開版本歷史後才決定刪,那一刻清單已在快取裡);拉不到時降級成不帶數字的句子,不編一個數字出來。
修正來源的 Dialog 由父層持有:展開列隨時會被 PrimeVue 銷毀重建,Dialog 放子表裡送出到一半就可能連元件一起消失。選單實例則可以留在展開列內部——觸發重建的是展開/收合其他列,而那個動作本身就會關掉 popup。
展開列的視覺降階(CM-1080,兩頁共用):展開列預設與主表同底色、子表又沿用同一套 DataTable 樣式,展開後讀起來像「主表下面又接了一張主表」,看不出這是某一列的附屬內容。三個手段各降一階——① 左側 accent 直線+整塊內縮(視覺上「掛在」上一列底下);② 底色改用 --bg-base 形成凹陷(主表是 --bg-card);③ 加標題列明講這是什麼(版本歷史/控制項細節)+一個筆數或編號 badge。子表的表頭一併弱化成一條分隔線(主表表頭是有底色的區塊,沿用會再次造成「兩張主表」錯覺)。⚠️ 亮色主題另疊一層 3.5% 暖色遮罩——--bg-base 的暖奶油與卡片白差距太小,不壓深就看不出凹陷,用暖色而非灰是為了維持整體暖調。四個 class(row-expansion-panel / -title / -count / -subtable)放全域 _theme-overrides.scss,因為管理頁的版本子表與控制項頁的控制項詳細都用同一套(依 FE 規範「2 個以上元件共用 → 全域」,各自 scoped 一份必然漂移)。
全部展開/全部收合(CM-1080,兩頁都有):按鈕放在展開欄的表頭——它管的正是這一欄,放到篩選列會讓人以為是另一種篩選;圖示隨狀態在 pi-angle-double-right(可展開)/ pi-angle-double-down(已全開,再按收合)之間切換。兩頁的作用範圍刻意不同:
- 管理頁作用於當前頁的列,且⚠️ 刻意不 invalidate 版本快取——展開 N 列=N 個子元件掛載,逐一失效就是一次打出 N 個請求;而快取內容最舊也只到「上一次載入列表」那一刻(fetchProfiles 會全清),與主列表那些欄位的新鮮度完全同級,多打 N 次請求換不到實際更新的資訊。要看最新狀態就重載列表,或單獨收合再展開該列(那條路徑仍會 invalidate)。
- 控制項頁只作用於當前篩選結果而非全部 234~708 條:展開列是純本地渲染沒有請求成本,但一次攤開幾百條會讓 DOM 膨脹到影響捲動;且使用者按下去的當下想的是「把我現在看到的這些攤開」,篩到 12 條卻展開 708 條反而困惑。
url 型的快照提示三態(CM-1075):url 型的控制項清單上方恆有一條提示說明「這是外部網址某一刻的快照」(file 型不出現——它有 sha256 對帳,清單就是掃描時會用的內容)。開頁比對來源驗證器後,這條提示有三種樣貌:
| 狀態 | 呈現 |
|---|---|
| 來源未變(含無法判定) | 藍色資訊條+info-circle:「內容取自外部網址的當下快照…」+解析時間。不給重新解析按鈕——沒過期時給按鈕等於鼓勵使用者對著沒變的來源重跑 CINC |
| 來源已過期 + 已排上重抽 | 「來源有更新,正在重新解析…」——與一般解析中分開講(使用者沒按任何按鈕卻看到轉圈,不講明原因會以為是卡住的殘留狀態)。輪詢路徑不必改動,BE 回應的狀態已是 pending,既有輪詢自然接手 |
| 來源已過期 + 排不動(公版遇上非平台管理員) | 橘色警示條+exclamation-triangle:「來源網址的內容已經更新,以下顯示的是先前的快照…」+「重新解析」按鈕。🔴 不可改成轉圈,那個圈永遠不會完成 |
提示條的邊框走
currentColor+color-mix隨變體切色(寫死藍色會在過期時變成「橘字藍框」);圖示也一併換——只換文案不換視覺的話,過期與否看起來一模一樣,等於沒講。「無法判定」與「來源未變」的表達刻意一致:分不分得開對使用者沒有差別。
控制項清單頁是獨立一頁而非 Dialog——內容量級(234~708 條)與搜尋/篩選/分頁的互動不適合塞進彈窗。摘要區三張卡(控制項總數 / 人工待判 / 自動檢查)點下去等同套用「檢查方式」篩選;「人工待判」那張刻意用警示色——實測 TWGCB-01-014 是 234 條裡 199 條人工待判、自動檢查只有 35 條,這個數字是使用者選基準前最該注意的。但覆蓋率低不等於基準壞掉(TWGCB 本來就有大量條文需人工判讀),文案用陳述不用指責。
互動模式沿用流程範本管理頁(segmented pill 篩選、DataTable lazy 分頁、confirm 停用),不另發明。無 socket——抽取進度靠 5 秒輪詢(低頻操作接推播不划算,且專案 SocketIO 走另一個 port);各寫入動作成功後重打 fetchProfiles()(新增另重置回第 1 頁)。首次載入以 LoadingState 蓋住整個表格區,且分類字典與工具目錄一起等(先畫再補對照會讓分類欄先閃一次原始 key 再變中文)。錯誤呈現:具體驗證錯誤(格式 / 大小 / 安全 / 結構 / 分類值不可用 / 名稱必填)由 axios interceptor 依 error code 出 toast(error-code.json 已補全 DETECTION_TOOLS_* 對應文案),Dialog 端 catch 留空不重複出錯。
6. API 規格¶
Envelope:成功 {"code": 1, "data": …}、失敗 {"code": 0, "msg": "…"}(return_response)。route 註冊:api/detection_tools/__init__.py(Blueprint 前綴 /api/1.0)。
三組路徑前綴各有語意,不可混用:
/detection-tool-profiles——主檔資源(一支基準)。🔴 拆表是內部資料模型變更,對外路徑刻意不改名(FEapi.js與既有 E2E 測案都指著它)。/detection-tool-profile-versions——從檔資源(某一版)。控制項與抽取狀態屬於某一版而非一支基準(同支的 v1 / v2 內容可以完全不同),故 uid 收的是版本 uid。/detection-profile-taxonomies——分類字典(全域字彙,root 維護、跨租戶共用),獨立路徑不掛在基準底下。
⚠️ 路由註冊順序有意義:/list、/menu 必須在 /<string:uid> 之前註冊,否則會被當成 uid 吃掉;/<uid>/referencing-profiles-count、/<uid>/referencing-usage、/<uid>/source 同理都要在裸的 /<uid> 之前。
6.1 總清單(本頁呼叫的全部 endpoint)¶
| 分類 | Method + Path | 說明 | 完整規格 |
|---|---|---|---|
| 讀 | POST /detection-tool-profiles/list |
分頁列表(一列=一支基準+當前版摘要;公用版排前面) | §6.2 |
| 讀 | GET /detection-tool-profiles/menu?detection_tool_uid= |
下拉 menu(未停用且有當前版,依工具分池;扁平陣列)——本頁不直接用,任務抽屜與執行紀錄消費(見 §11) | §6.2 |
| 讀 | GET /detection-tool-profiles/{uid} |
單筆詳細(含當前版摘要) | §6.2 |
| 讀 | GET /detection-tool-profiles/{uid}/versions?page=&page_size= |
🆕 版本歷史(獨立分頁;列表已內嵌當前版摘要,此端點供展開列與版本多時使用) | §6.4 |
| 寫 | POST /detection-tool-profiles |
新增 / 登記(file 型 multipart、url 型 JSON) | §6.3 |
| 寫 | PUT /detection-tool-profiles/{uid} |
🆕 編輯基本資料(名稱 / 描述 / 分類三軸,不升版) | §6.4 |
| 寫 | POST /detection-tool-profiles/{uid}/new-version |
版更(從檔 INSERT 新版+舊版退現行,主檔不動) | §6.4 |
| 寫 | POST /detection-tool-profiles/{uid}/fork |
fork 公用版成本租戶自有副本 | §6.4 |
| 寫 | POST /detection-tool-profiles/{uid}/copy-to-tool |
複製到另一工具池 | §6.4 |
| 寫 | PUT /detection-tool-profiles/{uid}/deactivate |
停用(軟刪) | §6.4 |
| 讀 | GET /detection-tool-profiles/{uid}/referencing-usage |
🆕 主檔層使用判定(底下任一版被用過即算,不穿透軟刪專案) | §6.4 |
| 讀 | GET /detection-tool-profile-versions/{uid}/referencing-usage |
🆕 版本層使用判定(穿透軟刪專案——擋下的理由必須是使用者看得見的) | §6.4 |
| 寫 | PATCH /detection-tool-profile-versions/{uid}/source |
🆕 修正來源(版號不動、回落 pending 並自動排重抽;用過即擋 409) |
§6.4 |
| 寫 | DELETE /detection-tool-profile-versions/{uid} |
🆕 硬刪某一版(控制項 CASCADE 帶走;用過 / 是當前版皆擋 409) | §6.4 |
| 寫 | DELETE /detection-tool-profiles/{uid} |
🆕 硬刪整支基準(版本與控制項兩層 CASCADE 帶走;底下任一版用過即擋 409) | §6.4 |
| 讀 | GET /detection-tool-profile-versions/{uid}/controls |
🆕 某一版的控制項清單(全量回傳+抽取摘要,前端自己分頁) | §6.5 |
| 讀 | GET /detection-tool-profile-versions/{uid}/extraction |
🆕 抽取狀態輪詢(回應刻意最小化,五個欄位) | §6.5 |
| 寫 | POST /detection-tool-profile-versions/{uid}/extraction |
🆕 手動重抽(抽取失敗後的補救途徑) | §6.5 |
| 讀 | GET /detection-profile-taxonomies |
🆕 分類字典(?axis= / ?include_inactive= / ?with_counts=) |
§6.6 |
| 寫 | POST /detection-profile-taxonomies |
🆕 新增分類 key(root only) | §6.6 |
| 寫 | PUT /detection-profile-taxonomies/{uid} |
🆕 改排序 / 停用啟用(root only;契約上改不了 key) | §6.6 |
| 寫 | DELETE /detection-profile-taxonomies/{uid} |
🆕 刪除分類 key(root only;已被引用一律 409) | §6.6 |
| 讀 | GET /detection-profile-taxonomies/{uid}/referencing-profiles-count |
🆕 停用 / 刪除前查引用數(軟提醒) | §6.6 |
| 讀(輔) | GET /detection-tools |
工具目錄——所屬工具下拉與工具名對照的來源 | 檢測工具管理 §6.2 |
⚠️ 手動重抽(
POST .../extraction)不在任何原始規格書裡——它是實作 FR-060.3 時順帶帶進來的補救途徑(抽取失敗後總得有辦法重試而不必重新上傳一次檔案),前端摘要區已在用。記在此處避免日後被當成來歷不明的端點。
6.2 列表 / 下拉 menu¶
[POST] /detection-tool-profiles/list¶
Request(RequestMetaSchema 分頁慣例):
{
"pager": {"page": 1, "page_size": 25, "with_total": true},
"sort": [],
"filters": {
"detection_tool_uid": "<uid>",
"scope": "SYSTEM",
"name": "TWGCB",
"target_type": "os",
"target_product": "Ubuntu 22.04 LTS",
"benchmark_family": "twgcb",
"is_active": true
}
}
| filters 欄位 | 型別 | 說明 |
|---|---|---|
| detection_tool_uid | string|null | 依所屬工具過濾 |
| scope | string|null | SYSTEM / TENANT(FE 來源範圍 pill:全部=不帶) |
| name | string|null | 名稱 like 過濾 |
| target_type | string|null | 🆕 標的類型(字典 key) |
| target_product | string|null | 🆕 標的產品(自由文字) |
| benchmark_family | string|null | 🆕 基準體系(字典 key) |
| is_active | bool|null | load_default=true(預設只看未停用);FE「顯示已停用基準」開關打開時送 null |
⚠️
current_only與source_type兩個 filter 已隨拆表移除:前者是版本的概念(主列表的一列是一支基準,沒有「只看現行」可言);後者則因來源型態現在屬於版本而非基準。
Response data[](DetectionProfileResponseSchema)——一列=一支基準,當前版摘要內嵌成巢狀物件:
| 欄位 | 型別 | 說明 |
|---|---|---|
| uid | string | 主檔對外識別。🔴 與版本 uid 是兩回事——profile:<uid> 契約指的是版本 uid |
| scope | string | SYSTEM / TENANT——FE 來源 Tag 與 fork 鈕條件的依據 |
| detection_tool_id / detection_tool_code | int / string|null | 所屬工具;code 由 service 補算(FE 以工具目錄對照顯示名) |
| name / description | string / string|null | 顯示名稱 / 描述 |
| target_type / target_product / benchmark_family | string|null ×3 | 🆕 分類三軸(皆可為 NULL=未分類) |
| is_active | bool | 主檔軟刪旗標(§4 兩態) |
| can_edit | bool | BE 算好的結論(scope != 'SYSTEM' or 平台管理員,與 service guard 同一條規則)——FE 直接用,不自己重算 |
| current_version | object|null | 🆕 當前版摘要(欄位見下表)。🔴 可以是 null——整支停用過的殘留主檔,FE 必須渲染得出來 |
| created_user / created_user_name / updated_user / updated_user_name | string|null | 審計欄位規範:login_name 配 nickname 成對回傳(app service 層批次查;system 映「系統」) |
| created_at / updated_at | datetime | %Y-%m-%d %H:%M:%S |
current_version(DetectionProfileVersionSchema,同一份 schema 也是版本歷史端點的元素):
| 欄位 | 型別 | 說明 |
|---|---|---|
| uid | string | 版本 uid——🔴 逐列沿用拆表前舊表的 uid,profile:<uid> 契約零轉換;控制項頁與抽取端點收的都是它 |
| version / is_current | int / bool | 版號 / 是否當前版 |
| source_type | string | file / url |
| file_id / file_name | int|null / string|null | 檔案型的 upload_files soft-ref 與原始檔名(service 批次補算) |
| url | string|null | 連結型的登記 URL |
| sha256 | string|null | 檔案型對原始壓縮檔 bytes 計算——Agent 取檔對帳的信任根;詳細 Dialog 以等寬字顯示。⚠️ 與 CINC 自算的 profile 內容雜湊不是同一個值 |
| supports | JSON|null | 🆕 從該版 inspec.yml 抽出的執行平台(如 [{"platform-family": "linux"}]),唯讀不可編——它是事實不是分類,故不是分類第四軸 |
| extraction_status / extraction_error | string / string|null | 🆕 抽取四態(§4)與失敗訊息原文 |
| control_count / pending_count | int|null ×2 | 🆕 控制項總數 / 人工待判數。抽取完成前是 null,與 0 是兩件事(0 條=抽到了但真的沒有) |
| created_user / created_user_name / updated_user / updated_user_name | string|null | 同上審計欄位規範 |
| created_at / updated_at | datetime | %Y-%m-%d %H:%M:%S |
分頁:FE 預設每頁 20 筆(可選 10 / 20 / 50),走 BE lazy 分頁——與專案既有列表頁(BpmnProcess 等)慣例對齊。排序固定:scope asc, name asc——'SYSTEM' < 'TENANT' 字典序,公用版天然排前,FE 不另排序。當前版由 map_current_versions() 一次批次查完,不是每列各打一次(N+1 反例)。
可見範圍是業務語意不是只靠 RLS:root 租戶的 allowed_tenant_paths 前綴涵蓋所有子租戶,只靠 RLS 的話平台管理員會看到全站每個租戶的私有基準;repo 顯式收斂 or_(scope=='SYSTEM', tenant_id==ctx.tenant_id),RLS 只是兜底。
出處:route detection_profile_route.py::DetectionProfileListRoute:107-135、schema api/detection_tools/serializers/detection_profile.py:17-95、service detection_profile_service.py::list_profiles:104-137。
[GET] /detection-tool-profiles/{uid}¶
單筆詳細,回應與列表單列同形(含 current_version)。出處:DetectionProfileDetailRoute.get:195-212。
[GET] /detection-tool-profiles/menu?detection_tool_uid=¶
detection_tool_uid 必帶(P7 分池:GCB 抽屜只該見 gcb 池)。只回未停用且有當前版的基準——current_version_id IS NULL(整支停用過的殘留)不進下拉,派工綁的是某一版,沒有版可綁就不該出現在選單裡。Response data[](DetectionProfileMenuResponseSchema):
| 欄位 | 型別 | 說明 |
|---|---|---|
| uid / name | string | 版本 uid 與基準顯示名 |
| value | string | 已組好的 profile:<uid> 下發格式——前綴規則只有 BE 一處知道(common/util/detection_profile_ref.py::PROFILE_REF_PREFIX),FE 直接放進任務 params.profile,不自己拼 |
| scope / source_type / version | string / string / int|null | 供下拉呈現輔助 |
| target_type / target_product / benchmark_family | string|null ×3 | 🆕 給 FE 分組用的料,不是分組結果 |
🔴 回應維持扁平陣列,不可改成巢狀分組(即使前端要按分類分組)。
JobExecutionDrawer.vue:282的for (const m of items) map[m.value] = m.name拿到巢狀會靜默失效——不報錯,只是任務抽屜的參數顯示直接退回裸的profile:<uuid>UUID。分組是前端的事(useProfileTaxonomy.js::groupByAxis)。
出處:route DetectionProfileMenuRoute:137-155、service list_menu:141-163。
6.3 新增 / 登記¶
[POST] /detection-tool-profiles¶
同一 endpoint 收兩種 content-type(route _payload():43-51 判 multipart/form-data 取 request.form,否則取 JSON):檔案型走 multipart(檔案在 file 欄位、其餘欄位在 form;FE 送 FormData 時不手動設 Content-Type,讓 axios 自帶 boundary);連結型走 JSON。
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| detection_tool_uid | string | 是 | 所屬工具(P7 必選;查無 → 404 DETECTION_TOOLS_404001) |
| name | string | 是 | 空白 → 400 DETECTION_TOOLS_400016;同工具同租戶同名已存在 → 409 DETECTION_TOOLS_409006 |
| source_type | string | 是 | file / url |
| file | multipart file | file 型是 | 打包檔——走下方驗證管線 |
| url | string | url 型是 | 只驗 http(s):// 開頭形狀;掃描時不驗可達性、不代管憑證 |
| description | string | 否 | 描述(≤2000 字 FE 限) |
| target_type / target_product / benchmark_family | string | 否 | 🆕 分類三軸。受控兩軸的值須是存在且未停用的字典 key,否則 400 DETECTION_TOOLS_400015;target_product 是自由文字不驗 |
| scope | string | 否 | 預設 TENANT;SYSTEM 僅平台管理員送得動(service guard)——🆕 FE 已有 root 專用的 scope 選擇欄(D18) |
⚠️
platform_hint欄位已退役(FR-060 D6):它的原始用途「適用平台」現在由從檔的supports(從inspec.yml自動抽取、唯讀)承擔,而分類需求由三軸承擔。舊值隨舊表留在detection_tool_profiles_deprecated_20260803,不搬新表、不在新表佔位。
檔案型驗證管線(common/util/detection_profile_archive.py::ProfileArchiveValidator,streaming 全程不落地解壓——只讀 entry metadata、不呼叫任何 extract):
| 順序 | 檢查 | 失敗碼 |
|---|---|---|
| 1 | 副檔名白名單 .zip / .tar / .tar.gz / .tgz(最長後綴優先比對)+ magic number 雙重確認(zip=PK\x03\x04 含空包 / spanned 變體、gzip=\x1f\x8b、tar=offset 257 ustar)——防改副檔名的假檔 |
400 DETECTION_TOOLS_400008 |
| 2 | 大小 ≤ 50MB(Content-Length 預檢+實讀累計雙保險) | 400 DETECTION_TOOLS_400009 |
| 3 | 逐 entry 安全檢查:路徑不得為絕對 / 含 .. 逸出(防 zip-slip);tar 的 symlink / hardlink 一律拒收;entry 數 ≤10,000、宣告解壓總量 ≤500MB、壓縮比 ≤200(1MB 以下小檔不看比值,防誤殺)。四類問題共用一個碼——細分會把防護細節洩漏給攻擊者 |
400 DETECTION_TOOLS_400010 |
| 4 | 結構驗證:頂層或一層目錄內須有 inspec.yml。只驗結構不驗語意正確性——壞 profile 掃了會 fail、錯誤可回溯,平台不做「上傳即保證能掃」的承諾(設計界線,非缺陷) |
400 DETECTION_TOOLS_400011 |
| 5 | sha256 計算(對原始壓縮檔 bytes)→ 落 detection_profile_versions.sha256 並回填 upload_files.sha256(上傳 adapter 只算 md5) |
— |
| 6 | 存儲:SYSTEM 走 upload_system_file()(root 租戶儲存+storage_scope=system)、TENANT 走一般上傳;save_dir="detection-profiles" |
— |
tar 系與 zip 的安全檢查共用同一套 entry 檢查器(僅迭代介面不同)——「按格式分支處理」正是 zip 路徑漏防的典型成因。source_type 與來源欄位互斥(file 無檔 / url 無字串 / 未知型態)→ 400 DETECTION_TOOLS_400012(DB chk_dpv_source_exclusive CHECK 是兜底,業務層先擋讓錯誤可讀)。
寫入落兩張表:主檔一列(名稱 / 分類 / 工具 / scope)+從檔 version=1, is_current=TRUE 一列,再把主檔 current_version_id 指過去。🔴 背景抽取必須在 app service 的 @transaction commit 之後才排——worker 是另一條執行緒、另一個 session,在交易內排會讀不到還沒 commit 的那一列。route 方法拿到 dto 時交易已結束,那裡是唯一同時滿足「已 commit」與「知道新版 uid」的位置(detection_profile_route.py::_schedule_extraction:55-77)。
出處:route DetectionProfilesRoute.post:157-190、service create_profile:185-262。
6.4 編輯 / 版本歷史 / 版更 / fork / 複製到其他工具 / 停用¶
[PUT] /detection-tool-profiles/{uid} 🆕¶
Request(JSON,全部選填;unknown=EXCLUDE 容忍 FE 回送整個實體):
{
"name": "TWGCB-01-014 Ubuntu 22.04",
"description": "政府組態基準",
"target_type": "os",
"target_product": "Ubuntu 22.04 LTS",
"benchmark_family": "twgcb"
}
| 欄位 | 型別 | 說明 |
|---|---|---|
| name | string|null | null=不改;空字串 → 400 DETECTION_TOOLS_400016;改名時做同工具同租戶同名檢查(改成自己現在的名字不算撞名) |
| description | string|null | null=不改;空字串=清空 |
| target_type / target_product / benchmark_family | string|null ×3 | null=不改;空字串=清空(回到「未分類」)。受控兩軸的值須是存在且未停用的字典 key,否則 400 DETECTION_TOOLS_400015 |
Response:與列表單列同形(含 current_version)。
🔴 不收 source_type / url / file——換來源走 /new-version。主檔一列 UPDATE,版本鏈完全不動(不新增從檔列、版號不變、current_version_id 不變、抽取不重跑):名稱是「這支基準叫什麼」不是「這一版的內容」,改個錯字就升一版會讓版號完全失去意義。
實作細節:「空字串=清空」要走
clear_fields()另一條路徑——update()會跳過值為 None 的欄位(BaseRepositoryImpl對只帶部分欄位的呼叫端是正確行為),少了這步的症狀是「清空按了沒反應也沒報錯」的靜默失效;清完還要重讀一次,否則回給前端的是清空前的狀態(假成功,下一次 F5 才變)。
出處:route DetectionProfileDetailRoute.put:214-232、service update_profile:263-345。
[GET] /detection-tool-profiles/{uid}/versions 🆕¶
Query:page / page_size(預設 1 / 25)。Response data[] 元素形狀=§6.2 的 DetectionProfileVersionSchema(與列表內嵌的 current_version 同一份 schema)。列表已內嵌當前版摘要,此端點供展開列與版本多時使用。出處:route DetectionProfileVersionsRoute:234-253、service list_versions:169-183。
[POST] /detection-tool-profiles/{uid}/new-version¶
Request(全部選填;multipart / JSON 同 §6.3 分流):source_type / file / url / description。名稱、所屬工具與分類不收(那些是主檔的屬性,走上面的 PUT)。來源型態允許換(file → url 或反向);帶了新來源重跑驗證管線;全部未帶=只改描述也算一次版更,沿用當前版 file_id / sha256 / url——讓「這一版對應哪個檔」在每一列上都是完整的(Agent 拿 payload 不必回溯前版)。寫入順序固定:先降當前版 is_current 再 INSERT version+1(partial unique index uq_dpv_profile_current 擋兩筆 current,順序是必要條件不是風格),並把主檔 current_version_id 指過去。commit 後排背景抽取。出處:service create_new_version:347-411。
[POST] /detection-tool-profiles/{uid}/fork¶
Request:{"name": "<新名稱>"}(未帶沿用來源名)。scope='TENANT' / tenant_id=本租戶 硬寫;分類三軸沿用;當前版的 file_id / url / sha256 沿用(共用同一 upload_files 實體——profile 檔不可變、版更=新列新檔,複製 binary 只會同內容存兩次)。同名檢查對本租戶池。副本若尚未抽取過會排一次抽取(_schedule_extraction_if_unextracted)。出處:service fork_profile:413-429。
[POST] /detection-tool-profiles/{uid}/copy-to-tool¶
Request:{"detection_tool_uid": "<目標工具>", "name": "<名稱>"}。目標工具=來源工具 → 400(FE 下拉已排除來源工具)。scope 沿用來源——把公用版複製到另一工具,結果仍是公用版(受眾沒變,只是換池),故此路徑走 _guard_system_writable(與 fork 的語意差異正在於此)。同名檢查對來源列的 tenant_id+目標工具池。出處:service copy_to_tool:432-456。
[PUT] /detection-tool-profiles/{uid}/deactivate¶
無 body。只降主檔 is_active——拆表後 current_version_id 與從檔 is_current 都保留(日後重新啟用不必重建版本鏈)。出處:deactivate_profile:458-473。
[GET] /detection-tool-profiles/{uid}/referencing-usage 🆕 + [GET] /detection-tool-profile-versions/{uid}/referencing-usage 🆕¶
兩支共用同一個回應形狀但語意不同(前者收主檔 uid、不穿透軟刪專案;後者收版本 uid、穿透)。判定規則見 §4「③ 使用判定」。
| 欄位 | 型別 | 說明 |
|---|---|---|
| in_use | bool | true = 已進入稽核軌跡,不可改不可刪 |
| projects[] | array | 擋住它的專案(uid / name)。⚠️ 只含未軟刪專案——軟刪專案使用者查不到,秀給他比不給理由更糟。主檔層可能出現 in_use=true 但此陣列為空的合法狀態 |
| version_refs[] | array | 哪一版被哪些階段引用(version_uid + sources:job_binding / agent_task / execution) |
實作落點刻意是獨立 query class(
infra/detection_tools/repository/detection_profile_usage_query.py)而非某個 repo:三個來源跨config/compliance兩個 schema 五張表,不屬任何單一聚合(比照DetectionJobNotifyQuery)。三來源 UNION ALL 一次取齊,專案一律LEFT JOIN——在 SQL 就INNER JOIN掉的話主檔層拿不到軟刪引用。⚠️ jsonb 存在判斷用
jsonb_exists()不可用LIKE '%_profile%':_是 LIKE 的單字元萬用字元,DEV 實測錯誤寫法回 32 筆、正確寫法 7 筆。
[PATCH] /detection-tool-profile-versions/{uid}/source 🆕¶
uid=版本 uid。file 型走 multipart(檔案在 file 欄位)、url 型走 JSON,同 §6.3 分流。
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| source_type | string | 是 | file / url——允許與原值不同(「選錯型態」正是最常見的修正情境,沿用原值會讓它表達不出來) |
| file | multipart file | file 型是 | 走與新增相同的驗證管線(§6.3) |
| url | string | url 型是 | 同新增,只驗 http(s):// 形狀 |
Response:該版的 DetectionProfileVersionSchema。⚠️ 不收 description 與分類三軸——那些在主檔,走 PUT /detection-tool-profiles/<uid>;混進來會讓「這支端點只動來源」的保證失效。
守門三道:capability detection-profile.create → _guard_system_writable(SYSTEM 列限平台管理員)→ 使用判定(用過 → 409 DETECTION_TOOLS_409009,訊息帶擋住它的專案名)。寫入語意見 UC-DPM-06。出處:route DetectionProfileVersionSourceRoute.patch、service update_version_source、repo replace_version_source。
[DELETE] /detection-tool-profile-versions/{uid} 🆕 + [DELETE] /detection-tool-profiles/{uid} 🆕¶
無 body。Response 刻意最小化(uid / name 或 version / deleted)——列已經不存在了,回整個 DTO 沒有意義。
| 端點 | 判定層 | 額外守門 | 連帶刪除 |
|---|---|---|---|
DELETE /detection-tool-profile-versions/{uid} |
版本層(穿透軟刪專案) | 🔴 當前版不可單獨刪 → 409 DETECTION_TOOLS_409011 |
該版控制項(CASCADE) |
DELETE /detection-tool-profiles/{uid} |
主檔層(不穿透) | — | 所有版本 + 所有控制項(兩層 CASCADE) |
兩道守門順序有意義:先擋 IN_USE 再擋 CURRENT。判定一律委派 CM-1077.1 的 canonical(_version_usage() / _profile_usage()),不在守門處重查引用、也不繞過去直接用 infra 的 query class——判定漏一個引用來源就是靜默放行刪除,只能有一份真相。詳見 UC-DPM-07。
Error code 一覽(common/code/detection_tools_error_code.py)¶
| 情境 | HTTP | code |
|---|---|---|
| 基準不存在 | 404 | DETECTION_TOOLS_404005 |
| 同工具同名已存在 | 409 | DETECTION_TOOLS_409006 |
| 壓縮格式不支援(副檔名 / magic number) | 400 | DETECTION_TOOLS_400008 |
| 超過 50MB | 400 | DETECTION_TOOLS_400009 |
| 壓縮檔不安全(slip / bomb / entry 數 / symlink,合併一碼) | 400 | DETECTION_TOOLS_400010 |
結構驗證失敗(找不到 inspec.yml) |
400 | DETECTION_TOOLS_400011 |
| source_type 與內容互斥違反 / URL 非 http(s) 形狀 / 複製目標工具=來源工具 | 400 | DETECTION_TOOLS_400012 |
| 非平台管理員寫公用版 | 403 | DETECTION_TOOLS_403001 |
| 🆕 分類值不存在或已停用 | 400 | DETECTION_TOOLS_400015 |
| 🆕 基準名稱空白 | 400 | DETECTION_TOOLS_400016 |
| 🆕 版本已被使用過(不可改來源/不可刪) | 409 | DETECTION_TOOLS_409009 |
| 🆕 主檔底下有版本被使用過(不可刪) | 409 | DETECTION_TOOLS_409010 |
| 🆕 當前版不可單獨刪除 | 409 | DETECTION_TOOLS_409011 |
🔴 三個 409 刻意分開而非壓成一個:三種擋下的補救動作完全不同——「版本用過了」只能建新版;「主檔底下有版本用過」可以改刪那些沒用過的版本、或改用停用;「這是當前版」則是先把 current 指標移走就能刪。壓成一個碼等於要使用者自己猜。三者的訊息都帶得出擋住它的專案名(沿用
delete_taxonomy帶引用數的做法),projects為空時降級成不帶專案名的句子。
DETECTION_TOOLS_400016是 FR-060 才獨立出來的:此前建立路徑沿用400012擋,但那個碼的訊息(「來源類型與內容不符」)與「名字沒填」毫無關係,使用者看了不知道要改什麼。分類字典自己的四個碼見 §6.6。
FE error-code.json(zh-tw / en)已補齊上列全部碼的文案,失敗由 axios interceptor 統一出 toast。
6.5 控制項抽取與瀏覽 🆕¶
為什麼要有這組功能:此前平台把 profile 當不透明二進位檔,使用者在管理頁與任務下拉只看得到一個名字——選了、掃了、拿到一份 85% 是 skip 的報告才發現白等一輪。抽取把「這支基準到底會驗什麼」攤開成一條條控制項。
抽取管線(app/detection_tools/service/detection_profile_extraction_service.py):
- 觸發點:新增(v1)、版更(新版)、fork / 複製到其他工具(尚未抽取過才排)、手動重抽。一律在 app service 的
@transactioncommit 之後排。 - 必須非同步:實測 234 條約 28–29 秒、708 條約 117 秒——117 秒遠超任何 HTTP 逾時設定。故 HTTP 立即返回、抽取排進背景執行緒,前端 5 秒輪詢(不走 WebSocket——低頻操作接推播不划算,且專案 SocketIO 走另一個 port)。
- 執行環境:BE 主機安裝 CINC Auditor 7.1.7(Apache 2.0),跑
cinc-auditor json <臨時檔>解析。路徑三層解析:環境變數CINC_AUDITOR_CMD→ PATH → 絕對路徑 fallback(/usr/local/bin→/opt/cinc-auditor/bin→/usr/bin),不硬編路徑。⚖️ 授權紅線:絕不可換成官方 InSpec 6+ 商業 binary 或inspec-coregem(受 Chef EULA)。詳見docs/claude/host-dependencies.md§③。 - 缺 CINC 的影響面:基準上傳/版更仍然成功,派工掃描完全不受影響(agent 吃的是壓縮檔本身,不是抽出來的控制項);只有抽取落
failed,錯誤訊息為「找不到 cinc-auditor 執行檔…」。 - worker 紀律:新執行緒的
ContextVar與SessionLocal都是空的,呼叫端捕捉 user context、worker 內還原並各自包session_scope();長時間的 CINC 執行刻意放在session_scope()之外(三段式:讀(短)→ 抽(長,無 session)→ 寫(短)),否則一次上傳會吃掉一條 DB 連線兩分鐘。最外層一律 catchException落failed——漏接會讓那一版永遠卡在running,使用者看到無盡轉圈,比明確的失敗訊息糟得多。
🔴 失敗判定含靜默失敗(三種結果分支):
| 分支 | 判定 | 為什麼 |
|---|---|---|
| 成功 | exit 0 且 controls 非空 |
— |
| 明確失敗 | exit != 0 | stderr 截 2,000 字落 extraction_error(CINC 的 Ruby 堆疊動輒二十行,有用的是第一行) |
| 靜默失敗 | exit 0、stderr 全空、JSON 合法,但 controls == [] |
.rb 有 Ruby 語法錯時 CINC 就是這個行為——只看 exit code 會讓使用者以為這份基準本來就沒東西 |
(另有 exit 0 但輸出非 JSON 的第四種,同樣落 failed——硬吞會變成另一種靜默失敗。)
抽取器註冊表(擴充骨架):管線寫成「依 profile_format 取 extractor」而非直接呼叫 CINC——新格式=新增 extractor 類別+註冊一行,不動管線骨架、不動既有工具路徑;「BE 裝 CINC」這項主機依賴因此只綁 inspec 格式,未來 XCCDF extractor 進來時沒裝 CINC 的環境仍可處理 XCCDF profile。工具端在 config.detection_tool_param_schemas 的 profile 欄位宣告 profile_format / supports_extraction(migration 2026-08-03-fr060-3-param-schema-extraction-declaration.sql,就地 UPDATE 不升版);未宣告時退回預設 inspec。註冊表在 common/util/profile_extractor/。
連結型(source_type='url')也抽得到:抽取時由 BE 代為下載(common/util/safe_http_fetch.py,含 SSRF 五道防護),拿到 bytes 後完全共用 file 型既有路徑。三條不變式:① 掃描路徑完全不動——agent 仍原樣把 URL 交給 cinc-auditor exec;② 下載內容不落儲存——不寫 upload_files、source_type 維持 url、file_id / sha256 維持 NULL(存成 file 型快照會破壞雙軌語意,且掃描與抽取從此可能對不上時沒人知道該信哪個);③ 下載在 session_scope() 之外(最多等 60 秒的網路 IO)。url 型抽出來的是某一刻的快照(master branch 會漂)、沒有 sha256 當信任根——這件事由前端明示給使用者。
⚠️ 這一點推翻了 FR-059 時期「連結型無法抽取、狀態恆為 failed」的設計假設:實測發現 CINC 原生就吃 URL,那道限制是產品決策的範圍問題(P5「掃描路徑不代管私有 repo 憑證」)而非技術限制,而抽取是 FR-060 才有的另一個面向。
🆕 url 型的來源過期偵測(CM-1075):url 型有一個先天落差——掃描抓的是 URL 上的即時內容,控制項明細是平台某次抽取的快照,上游改了平台不會知道,兩者靜默偏離。開控制項明細頁時(list_controls())對來源發一次輕量 HEAD(probe_url_validators(),零 body、5 秒逾時),拿 ETag 跟抽取當下記在從檔的 source_etag 比對:
| 比對結果 | 行為 |
|---|---|
| 相同 | 什麼都不做,直接回現有清單(多的成本只有一次 HEAD,載入耗時與 file 型同級) |
| 不同 | 回 source_outdated=true + 立刻排背景重抽(壓得動狀態的話) |
| 取不到/探測失敗 | 🔴 判「無法判定」,回 source_outdated=false,照常顯示現有清單 |
不做比對的四種情況(一律回 false):非 url 型(file 型有 sha256 當信任根)/尚未成功抽過(沒有快照可過期)/沒有記錄過驗證器(既有資料不回填,見下)/探測失敗。
- 🔴 判太鬆比判太嚴更糟(本功能最重要的取捨):判成有變更會讓那類來源每次開頁都重跑一次 CINC(234 條 28 秒、708 條 117 秒),正好退化成當初被排除的「開頁無條件重抽」方案,而且症狀是靜默的——畫面一切正常,只有主機在燒。ETag 取不到時也不退回下載內容比雜湊(那等於每次開頁拉一份 tarball)。
- 比對優先用 ETag(強驗證器),兩邊都有 ETag 時就不看
Last-Modified——混用兩個驗證器會讓「ETag 相同但 Last-Modified 因重新打包而變了」誤判成有變更,而 GitHub 這類動態產生 tarball 的來源正好會出現那種情況。存兩欄是因為來源分兩派:實測 GitHub codeload 只給 ETag 不給 Last-Modified,一般靜態檔案伺服器常常相反。 - 🔴
Accept-Encoding必須釘死identity:實測 ETag 隨內容編碼協商而變(identity → 強驗證器、gzip → 弱驗證器W/"…",值完全不同),而 httpx 預設送gzip, deflate。探測與下載若送出不同的Accept-Encoding,比對永遠不相等 → 每次開頁重跑 CINC。來源本身已是壓縮檔,再 gzip 一次效益近零。 - 公版遇上非平台管理員 → 降級成靜態提示,不排一個必定失敗的 worker:RLS 的 UPDATE policy 不放行非 super_admin 對
scope='SYSTEM'的寫入,硬排 worker 只是把同一個失敗搬到背景,而使用者會看到一個永遠轉不完的圈。故先判後寫,壓不動就回False讓 FE 顯示「來源已更新、以下是舊快照」的靜態提示+重新解析按鈕。租戶自己的 url 型基準不受影響,照常自動更新。 - 驗證器只在抽取成功時落庫,且兩欄一律一起寫(含寫成
None):失敗的抽取若也記下「我已經抽過這個 ETag」,下次開頁會判「內容沒變」而不再重抽,那一版就永遠停在失敗態且沒有自動復原的路徑;只更新其中一欄則會留下前一輪的殘值。 - 既有 13 筆不回填:回填拿到的 ETag 對應的是現在的內容而非當初抽取時的,若上游已改過等於把過期快照標記成最新,比留 NULL 更糟。NULL 的行為是「沒有基準可比 → 不判定」,下一次重抽時自然補上。
- 不變式仍未被打破:探測與下載共用同一套 SSRF 防線(唯一差別是動詞,只有「大小上限」不適用);新增的兩欄存的是來源的 HTTP 驗證器字串(內容指紋,不是內容),
source_type仍是url、file_id/sha256仍是 NULL;網路 IO 仍在session_scope()之外——list_controls()因此刻意拿掉@transaction,拆成讀(_read_controls,有@transaction)→ 探測(無 session)→ 排重抽三段。
⚠️ 「來源已過期」這一態未經實際重現驗證(要遠端內容真的變動才觸發,成本不成比例),是基於「與一般抽取共用同一條程式碼路徑」而推斷;其餘四態(未變 / 無法判定 / file 型不觸發 / 未記錄驗證器不探測)皆有 DEV 實測。
落庫的形狀:code 欄位與與 description 重複的 desc 不落庫(合計省 59.6% 體積);source_ref 剝掉絕對路徑前綴只存相對路徑(來源的 source_location.ref 是執行當下的絕對路徑,含臨時目錄名,不剝會存進無意義且洩漏內部路徑的字串)。
[GET] /detection-tool-profile-versions/{uid}/controls¶
uid=版本 uid(從檔)。全量回傳供前端快取,連摘要欄位一起回,不必先打狀態端點再打清單:
| 欄位 | 型別 | 說明 |
|---|---|---|
| version_uid | string | 回聲 |
| extraction_status / extraction_error | string / string|null | 抽取四態與失敗原文 |
| control_count / pending_count | int|null ×2 | 總數 / 人工待判數(抽取完成前 null) |
| source_type / extracted_at | string|null / datetime|null | url 型才顯示的「這是快照」提示與解析時間所需 |
| source_outdated | bool | 🆕 開頁比對來源的結論(CM-1075):true =上游內容已與這份快照不同。false 同時涵蓋「確認相同」與「無法判定」(前端對兩者的表達一致,分不分得開對使用者沒差別) |
| controls[] | array | 控制項清單(下表) |
🔴
source_type/extracted_at/source_outdated只有這支端點會回,輪詢端點(下一節)刻意最小化不含它們——前端的輪詢不可覆蓋這三個欄位,混進去會讓提示在抽取完成那一刻閃掉。重載時一律以新回應為準:抽完會拿到false,提示自然消失,不必額外寫「抽完把旗標清掉」那種遲早會漏的手動同步。
controls[] 元素(DetectionProfileControlSchema):
| 欄位 | 型別 | 說明 |
|---|---|---|
| uid | string | 對外識別 |
| control_id | string | 控制項編號(如 twgcb_01_014_0079)。跨版本識別靠它,但不保證穩定;刻意不設 (version_id, control_id) 唯一約束——來源檔可能因 depends 等機制出現重複編號,硬約束會讓整支抽取失敗 |
| title / description | string|null ×2 | 標題 / 說明(取來源 descriptions.default) |
| severity_raw | string|null | 原樣存來源值的文字形式(InSpec 存 impact 的 "0.0"/"1.0")。⚠️ 欄位刻意不叫 impact——那是 InSpec 專有的 0.0–1.0 浮點語意,XCCDF 是五級列舉,寫死會讓第二個格式進來時只剩改表一條路 |
| severity_norm | string|null | 跨工具可比的統一級距。🔴 none 專指「不做自動判定」(InSpec impact 0.0),與「風險低」是兩件事——前端不畫成 low 的顏色而走中性灰底的「不判定」,否則 199 條人工待判項會被誤讀成 199 個低風險項 |
| is_pending | bool | 人工待判標記。判準寫在各格式的 extractor 裡(InSpec 是 impact == 0.0)不是表結構假設——用 tags.check_type == pending 會漏 6 條 |
| source_ref | string|null | 相對路徑(controls/xxx.rb),已剝掉絕對路徑前綴 |
| extraction_format | string | 哪個格式抽出來的(inspec / …),讓讀取端知道該期待什麼 |
| origin | string | extracted=從 profile 抽出 / custom=自訂項。🔴 與 extraction_format 正交,兩者不可合併成一個欄位 |
| attributes | JSON | 格式專屬中繼資料整包(InSpec 放 tags + descriptions)。⚠️ key 集合隨 profile 變動(實測 TWGCB-01-011 比 01-014 多一個 service_display_name),前端展開列走「有才顯示」的動態走訪,不假設固定 key |
前端全量載一次自己分頁(:paginator 25 筆/頁),搜尋/篩選/分頁全在前端做——server-side lazy loading 對「某一版的靜態內容」是純粹的往返浪費。實測 234 條回應約 300KB,未達引入 VirtualScroller 的門檻(且 VirtualScroller 在本專案零使用,為此開新 pattern 不划算)。
[GET] /detection-tool-profile-versions/{uid}/extraction¶
輪詢用,回應刻意最小化(uid / extraction_status / extraction_error / control_count / pending_count)。🔴 不拿 controls 端點當輪詢用——那支會把 234~708 條整包重回。
[POST] /detection-tool-profile-versions/{uid}/extraction¶
手動重抽(無 body)。先把狀態壓回 pending 再排——否則前端輪詢會看到還停在舊的 failed。會覆寫該版控制項,是寫入動作,故對 detection-profile.create(與版更同一組權限,兩者都是「產生這一版的內容」)。
6.6 分類三軸與分類字典 🆕¶
三軸定義:
| 軸 | 欄位 | 值域 | 說明 |
|---|---|---|---|
| ① 標的類型 | target_type |
受控 enum:os 作業系統/browser 瀏覽器/application 應用程式/database 資料庫/network_device 網通設備/cloud_service 雲端服務/container 容器/other 其他 |
值存字典 key |
| ② 標的產品 | target_product |
自由文字(Ubuntu 22.04 LTS / Google Chrome / PostgreSQL 15…) |
產品名長尾極長(客戶自帶的 profile 可能是任何東西),硬做 enum 會逼使用者選「其他」而失去篩選價值;前端從既有值做 autocomplete 建議 |
| ③ 基準體系 | benchmark_family |
受控 enum:twgcb / cis / stig / dev_sec / custom |
值存字典 key |
⚠️ 執行平台(
supports)不是第四軸——它從inspec.yml自動抽取、唯讀,屬於版本從檔。它是事實不是分類,使用者無從編輯。key 一律小寫 snake_case(DB
ck_dpt_key_slug護欄),dev-sec的官方寫法帶連字號但 key 用dev_sec——顯示成「dev-sec」是前端 i18n 的事。
🔴 顯示名稱不落庫,翻譯由前端 i18n 負責(不開翻譯表、不走 failover——為了幾個分類值多一張翻譯表與一套 failover 讀取鏈,維護成本遠高於收益)。前端三層 fallback(useProfileTaxonomy.js):① i18n key 存在(用 te() 測)→ 顯示翻譯;② 否則顯示 DB 回傳的 key 本身;③ 現值不在標準選項內(舊自由文字)→ 保留在選項最前不讓它變空。
🔴 這條優雅降級一定要記住:root 在 DB 新增一個分類 key 之後,前端若還沒有對應 i18n 條目,該值會以原始 key 顯示——畫面上會出現
network_device而不是「網通設備」,要等前端補上翻譯並發版才會正常顯示。不會壞、不會空白、不會報錯,篩選與分組全部照常。看到英數 slug 不要當成 bug 去追。i18n 條目落點:
lang.detection_profile.taxonomy_<軸>_<key>(detection-profile-manage.json)。
維護規則:
- 契約上改不了 key——
PUT的 schema 刻意沒有key欄位。key 是既有基準引用的值,改掉等同刪掉再新增,會製造懸空孤兒。schema 不收比在 service 裡用if擋更誠實:呼叫端從契約上就看得出改不了。 - 刪除保護:已被基準引用的 key 不可刪 → 409
DETECTION_TOOLS_409008,訊息帶引用數讓 root 知道擋在哪。要下架請用停用(PUT is_active=false)——停用不做引用檢查,既有引用仍解得出這個 key(分類骨幹是歷史紀錄的一部分,本來就不該被抹掉)。 - 刪除保護在應用層不用 FK:基準存的是 key 字串不是 id(soft-ref),改成 FK 會牽動搬遷 SQL 與刻意留 NULL 的那些列。
[GET] /detection-profile-taxonomies¶
Query:?axis=target_type|benchmark_family(不帶=兩軸全部)/?include_inactive=true(連停用的一起回,管理端用)/?with_counts=true(帶引用數)。
Response data[](DetectionProfileTaxonomyResponseSchema):
| 欄位 | 型別 | 說明 |
|---|---|---|
| uid | string | 對外識別 |
| axis | string | target_type / benchmark_family |
| key | string | 🔴 唯一會被基準引用的值;回應沒有顯示名稱欄位——不是漏掉,是刻意的(見上方) |
| sort_order | int | 下拉與分組的顯示順序(小的在前)。「未分類」不是一列——它是基準的 NULL 值,由前端排在最後 |
| is_active | bool | 停用後不出現在新建/編輯選項,但既有引用仍解得出 |
| reference_count | int|null | 只在 with_counts=true 時帶值,FE 不該依賴它一定有值 |
參數是「要不要含停用」(
include_inactive)而不是布林篩選(is_active=false)——後者只回得到停用那幾筆,管理頁反而拿不到完整列表。
[POST] /detection-profile-taxonomies(root only)¶
Request:{"axis": "target_type", "key": "network_device", "sort_order": 50}。🔴 沒有 is_active——新 key 一律啟用(建了就是要用的)。
[PUT] /detection-profile-taxonomies/{uid}(root only)¶
Request:{"sort_order": 50, "is_active": false}。沒有 key(見上)。
[DELETE] /detection-profile-taxonomies/{uid}(root only)¶
已被引用一律 409。
[GET] /detection-profile-taxonomies/{uid}/referencing-profiles-count¶
停用/刪除前的軟提醒(比照檢測工具管理的 referencing-tasks-count)。
Error code(分類字典,common/code/detection_tools_error_code.py)¶
| 情境 | HTTP | code |
|---|---|---|
| 分類值不存在 | 404 | DETECTION_TOOLS_404006 |
| 同一軸下 key 已存在 | 409 | DETECTION_TOOLS_409007 |
| 已被基準引用不可刪 | 409 | DETECTION_TOOLS_409008 |
| axis 不在受控白名單 | 400 | DETECTION_TOOLS_400013 |
| key 不符 slug 格式 | 400 | DETECTION_TOOLS_400014 |
7. 前端檔案地圖(compliance-manager-fe/)¶
| 檔案 | 角色 |
|---|---|
src/views/detection-profile/DetectionProfileManageView.vue |
主檢視(801 行;FR-060.2 T-2.3 由原 1,239 行單檔拆成本檔+5 個子元件):篩選列(scope pill / 工具下拉 / 兩個分類下拉 / 搜尋 debounce / 停用開關)、lazy 分頁 DataTable +版本歷史展開列(含展開欄表頭的全部展開/收合 allExpanded / toggleExpandAll,刻意不 invalidate 快取)、單一 kebab 操作選單(buildActionMenuItems 組 model、rowReadonlyReason 算整列唯讀原因、capLabel 後綴缺權限標記、openActionMenu 用 nextTick 等 model 重算再 toggle 定位)、來源欄的鎖 icon、「未分類」前端過濾 |
src/views/detection-profile/components/ProfileFormDialog.vue |
🆕 新增/編輯共用 Dialog(433 行):source_type 切換 file / url 欄位、multipart vs JSON 分流、分類三軸(兩個下拉+產品 AutoComplete)、root 專用 scope 欄(!isEdit && isPlatformAdmin)。編輯模式不渲染來源與 scope 欄位 |
src/views/detection-profile/components/ProfileNewVersionDialog.vue |
🆕 版更 Dialog(沿用當前版的 source_type 分流) |
src/views/detection-profile/components/ProfileVersionTable.vue |
🆕 版本歷史展開列子表:展開才打 API、模組層快取+invalidateVersionCache();抽取狀態徽章、逐版的「檢視控制項」鈕(CM-1078 未動它——主列表那顆只看得到現行版,歷史版仍只能從這裡進去;CM-1077.3 起這顆與「修正來源」「刪除此版本」一起收進版本列自己的 kebab,三顆並排會擠爆 4rem 欄寬)。另 export loadVersions() 供父層刪除確認框算「N 個版本、M 條控制項」——刻意共用同一份快取而非另開讀取路徑。外框走全域 row-expansion-panel +標題列「版本歷史」+筆數 badge、子表套 row-expansion-subtable(CM-1080);抽取狀態欄補 align-items-start(不加會被 flex-column 把 Tag 拉滿欄寬像進度條)、版號欄 7rem → 8rem(亮色主題「v1 + 現行版」會折兩行) |
src/views/detection-profile/components/ProfileDetailDialog.vue |
🆕 詳細資料唯讀 Dialog(含分類三軸與 sha256 等寬字) |
src/views/detection-profile/components/ProfileCopyDialog.vue |
🆕 fork /複製到其他工具共用 Dialog |
src/views/detection-profile/components/ProfileSourceDialog.vue |
🆕 修正來源 Dialog(CM-1077.3):source_type 是真正可改的欄位(不是唯讀顯示)、url 型預填現值;文案先講明「送出後這一版的控制項會全部重新解析」。由父層持有(展開列會被 PrimeVue 銷毀重建) |
src/composables/useProfileUsage.js |
🆕 使用判定查詢與訊息組裝(CM-1077.3):模組層快取(主列表與版本子表共用同一份)、invalidateUsageCache()(任何寫入後作廢)、usageReason(usage, t, level) 依 'version' / 'profile' 給不同下一步(建新版本 vs 改用停用)。🔴 判定失敗回的 UNKNOWN 是 {in_use: true, unknown: true}——刻意不回 false,判定回錯方向會放行不該放行的刪除 |
src/views/detection-profile/profileDisplay.js |
🆕 顯示層共用小工具:fmtDateTime / canWriteProfile(拆表後不再看 is_current)/ profileStatus(兩態)/ extractionBadge(未知狀態顯示原始字串)/ EXTRACTION_IN_PROGRESS |
src/views/detection-profile/DetectionProfileControlsView.vue |
🆕 控制項清單頁(721 行):路由收版本 uid、開頁一次請求(controls 端點含摘要)、前端搜尋/檢查方式/嚴重度篩選+client-side paginator、pending/running 時 5 秒輪詢最小狀態端點、手動重抽;展開列(完整說明+動態 attributes)改套全域 row-expansion-panel 並加標題列「控制項細節」+控制項編號 badge,展開欄表頭同樣加全部展開/收合(⚠️ 只作用於 filteredControls)(CM-1080) |
src/views/detection-profile/components/ControlExtractionSummary.vue |
🆕 摘要區:三張卡(總數 / 人工待判 / 自動檢查,點下去套篩選)+抽取四態各自的表達+重抽鈕;pending 空狀態文案明示「還沒觸發過」不是失敗 |
src/composables/useProfileTaxonomy.js |
🆕 分類字典:模組層快取(管理頁與 N 個任務抽屜同時掛載只打一次)、labelOf 三層 fallback、optionsOf、groupByAxis |
src/service/DetectionProfileTaxonomyService.js |
🆕 分類字典 API 包裝:listTaxonomies / createTaxonomy / updateTaxonomy / deleteTaxonomy / countReferences。⚠️ 寫入四支目前無任何 view 消費(見 §12 坑 2) |
src/service/DetectionToolProfileService.js |
API 包裝:listProfiles / listMenu / getProfile / createProfile / updateProfile / createNewVersion / listVersions / forkProfile / copyToTool / deactivateProfile / listControls / getExtractionStatus / retryExtraction / 🆕 getProfileUsage / getVersionUsage / updateVersionSource / deleteProfile / deleteVersion——FormData 不手動設 Content-Type(缺 boundary 會 400) |
src/composables/useDetectionProfileOptions.js |
任務抽屜下拉用(非本頁):options_source: "profile_library" 欄位的 menu 選項載入——模組層級快取(同工具只打一次 API、in-flight promise 去重;規劃頁 N 個任務列共用)+invalidate();isProfileLibraryField() 宣告判定 |
src/components/detection-tools/DetectionConfigField.vue |
動態欄位渲染元件(與檢測工具管理 / 任務配置共用):FR-059 起 options_source 宣告欄位的 options 改接 profile menu API(帶 detectionToolUid prop 分池);已停用參照補唯讀 fallback 選項、庫拉不到顯示警示但手填照常(見 §11) |
src/config/router/index.js:630-634 |
route detection-profile-manage,path /plugin/detection-profile-manage |
src/config/router/index.js:645-652 |
🆕 route detection-profile-controls,path /plugin/detection-profile-manage/versions/:versionUid/controls(麵包屑 parent=detection-profile-manage) |
src/config/api/api.js:355-378 |
DETECTION_TOOL_PROFILE_LIST / _MENU / DETECTION_TOOL_PROFILES / _DETAIL / _NEW_VERSION / _FORK / _COPY_TO_TOOL / _DEACTIVATE / 🆕 _VERSIONS / 🆕 DETECTION_PROFILE_VERSION_CONTROLS / _EXTRACTION / 🆕 DETECTION_PROFILE_TAXONOMIES / _TAXONOMY_DETAIL / _TAXONOMY_REF_COUNT 常數 |
src/assets/_theme-overrides.scss |
全域樣式(非本頁專屬,但四段由本頁引入):.row-expansion-panel / .row-expansion-title / .row-expansion-count / .row-expansion-subtable 展開列降階(含亮色主題的暖色遮罩覆寫),管理頁版本子表與控制項頁展開列共用;另有 .p-menu .menu-item-danger(kebab 選單的停用項紅字,.p-disabled 時不覆寫顏色讓 PrimeVue 的 opacity 正常壓成不可用外觀)與 .p-menu .dpm-menu-note(選單頂端唯讀說明列)——Menu popup 預設 appendTo="body",元件 scoped style 到不了,必須放全域 |
src/config/locales/i18n/{zh-tw,en}/detection-profile-manage.json |
頁面 i18n(detection_profile namespace,獨立檔)——🆕 含 13 個 taxonomy_<軸>_<key> 分類名稱條目與控制項頁全部文案;CM-1078/1080 新增 btn_actions_menu(操作選單 / Actions)、btn_expand_all / btn_collapse_all、versions_caption(版本歷史)、controls_detail_caption(控制項細節);CM-1075 新增 controls_url_outdated_hint / controls_outdated_refreshing_title / _desc;CM-1077.3 新增 btn_fix_source / btn_delete / btn_delete_version、六個 usage_{version,profile}_{in_use,in_use_no_project,unknown}(_no_project 那兩支是給「引用全在軟刪專案」的降級用)、dialog_delete_* / dialog_fix_source_* / hint_fix_source 與三個 toast |
src/config/locales/i18n/{zh-tw,en}/menu.json |
選單標題「掃描設定檔管理」+描述 |
src/config/locales/i18n/{zh-tw,en}/error-code.json:187-200 |
全部 DETECTION_TOOLS_* 碼的使用者文案(上傳驗證八碼+分類字典五碼+名稱必填) |
8. 後端檔案地圖(本頁核心鏈路)¶
| 鏈路 | Route | App Service | 底層 |
|---|---|---|---|
| 列表 / menu / 詳細 / 版本歷史 | api/detection_tools/routes/detection_profile_route.py::DetectionProfileListRoute / MenuRoute / DetailRoute.get / VersionsRoute |
app/detection_tools/service/detection_profile_service.py::list_profiles / list_menu / get_profile / list_versions(皆 @transaction) |
domain/detection_tools/service/ → infra/detection_tools/repository/detection_profile_repo_impl.py(繼承 BaseRepositoryImpl,session lazy;map_current_versions 批次補當前版避 N+1) |
| 新增 / 登記 | DetectionProfilesRoute.post |
create_profile(驗證管線 → 存儲 → INSERT 主檔+從檔 v1) |
同上+common/util/detection_profile_archive.py::ProfileArchiveValidator(DI 注入)+app/upload_file/service/managed_file_upload_service.py(canonical 儲存,upload_system_file / upload_files) |
| 編輯基本資料 | DetectionProfileDetailRoute.put |
update_profile(主檔一列 UPDATE;空字串走 clear_fields() 清成 NULL 再重讀) |
同 repo(find_by_exact_name 撞名前置) |
| 版更 / fork / 複製 | NewVersionRoute / ForkRoute / CopyToToolRoute |
create_new_version / fork_profile / copy_to_tool |
同 repo(先降舊版 is_current 再 INSERT;_clone_profile 共用複製骨架) |
| 停用 | DeactivateRoute.put |
deactivate_profile(只降主檔 is_active) |
同 repo |
| 使用判定 | DetectionProfileUsageRoute.get(主檔 uid)/ DetectionProfileVersionUsageRoute.get(版本 uid) |
get_profile_usage / get_version_usage(外層有 @transaction)+ _profile_usage / _version_usage(無 @transaction 的實作本體,供已在交易內的守門呼叫)→ 共同收斂到 _build_usage(versions, skip_soft_deleted) |
infra/detection_tools/repository/detection_profile_usage_query.py(獨立 query class 非 repo——跨 config / compliance 五張表不屬任何單一聚合;session 走 @property lazy)+domain list_all_versions()(不分頁——判定漏掉第 26 版即誤放行,不可共用會截斷的 list_versions()) |
| 修正來源 | DetectionProfileVersionSourceRoute.patch |
update_version_source(三道守門 → _resolve_source → 四欄單次寫定 → route 在 commit 後排重抽) |
replace_version_source()(新的一支不拼 update() + clear_fields()——行級 CHECK chk_dpv_source_exclusive 要求四欄同次 flush) |
| 刪除(硬刪) | DetectionProfileDetailRoute.delete(主檔)/ DetectionProfileVersionDetailRoute.delete(版本) |
delete_profile / delete_version(判定委派 canonical 的 _profile_usage / _guard_version_unused,不重查引用) |
delete_profile() / delete_version();連帶刪除靠 DB 兩層 ON DELETE CASCADE |
| 控制項 / 抽取狀態 / 重抽 | DetectionProfileVersionControlsRoute / DetectionProfileVersionExtractionRoute(GET+POST) |
app/detection_tools/service/detection_profile_extraction_service.py::list_controls / get_status / retry / schedule(背景 worker 自行重建 user context 與 session) |
common/util/profile_extractor/(註冊表 + inspec.py::InspecProfileExtractor)+common/util/safe_http_fetch.py(url 型代下載,SSRF 防護) |
| 分類字典 | api/detection_tools/routes/detection_profile_taxonomy_route.py::ListRoute / DetailRoute / RefCountRoute |
app/detection_tools/service/detection_profile_taxonomy_service.py::list_taxonomies / create_taxonomy / update_taxonomy / delete_taxonomy / count_references |
infra/detection_tools/repository/detection_profile_taxonomy_repo_impl.py |
| 下發參照契約 | —(非 HTTP) | common/util/detection_profile_ref.py——PROFILE_REF_PREFIX / PROFILE_PARAMS_KEY / build_profile_ref() / extract_profile_uid() / build_payload(),前綴判斷只有這一份,派工端與 menu DTO 都 import 這裡 |
— |
| ORM Model | — | — | infra/detection_tools/model/detection_profile.py / detection_profile_version.py / detection_profile_control.py / detection_profile_taxonomy.py(前三者 TenantScopedMixinModel——before_flush 自動填 tenant_id;字典表是全域字典不掛租戶) |
DDD 提醒:route 不碰 session、app service 全數 @transaction、repo 繼承 BaseRepositoryImpl;寫入守門 route 層 capability decorator(主體域)+service 層 scope guard(資源域)雙軌,符合 FR-048 規範。
🔴 抽取排程刻意寫在 route 層而非 app service 內(
_schedule_extraction:55-77)——必須在@transactioncommit 之後才排,否則 worker(另一條執行緒、另一個 session)讀不到還沒 commit 的那一列。route 方法拿到 dto 時交易已結束,是唯一同時滿足「已 commit」與「知道新版 uid」的位置。這是刻意的例外,不是 route 層洩漏業務邏輯(route 只呼叫一個 helper,不做任何判斷或查詢)。
9. DB¶
9.1 資料表總清單(本頁讀寫的全部表)¶
| 表 | 讀/寫 | 說明 | 欄位詳述 |
|---|---|---|---|
| config.detection_profiles | 寫 | 🆕 檢測基準主檔(一列=一支基準:名稱 / 描述 / 分類三軸 / 所屬工具 / scope / 啟用旗標) | §9.3 |
| config.detection_profile_versions | 寫 | 🆕 版本從檔(一列=一版:來源 / sha256 / 版號 / supports / 抽取狀態與統計) | §9.3 |
| config.detection_profile_controls | 寫(抽取管線) | 🆕 控制項(一條一列) | §9.3 |
| config.detection_profile_taxonomies | 寫(root 維護分類字典) | 🆕 分類 enum 全域字典(受控兩軸的 key 值域) | §9.3 |
| public.upload_files | 寫(檔案型上傳時) | 檔案實體 metadata(file_id soft-ref 目標;sha256 回填) |
證據管理群 |
| config.detection_tools | 讀 | 工具目錄(所屬工具綁定與 code 對照) | 檢測工具管理 §9.3 |
| config.detection_tool_param_schemas | 讀(抽取器解析格式) | profile 欄位的 options_source: "profile_library" 宣告(本頁資料成為該宣告的下拉來源)+🆕 profile_format / supports_extraction 抽取宣告 |
任務配置 §9.3 |
| config.job_execution_detection_tools | 讀(🆕 使用判定來源①) | 任務已綁定的 tool_params->>'profile' |
任務配置 |
| compliance.agent_tasks | 讀(🆕 使用判定來源②) | 已產生的派工單 params->'_profile'->>'uid'(⚠️ 只有 url 型的 uid 是版本 uid) |
我的任務 |
| compliance.detection_executions | 讀(🆕 使用判定來源③) | 執行歷史,經 agent_task_uid 接回② |
我的任務 |
| project_extensions | 讀(🆕 軟刪穿透判準) | deleted_at——版本層判定用它濾掉已軟刪專案的引用(不可用 projects.status='archived') |
專案總覽 |
| ~~config.detection_tool_profiles~~ | 已退役 | 拆表前的單表,2026-08-03 rename 為 detection_tool_profiles_deprecated_20260803(未 DROP,觀察期後另案);未搬遷的 platform_hint 值留在裡面 |
— |
9.2 ER 圖¶
⚠️ 此 ER 圖畫的是 FR-059 的單表模型,FR-060 拆表後與現況有落差,待重繪。拆表後的關係一句話:
detection_profiles(主檔)1─Ndetection_profile_versions(從檔,FK +ON DELETE CASCADE)1─Ndetection_profile_controls(控制項,FK + CASCADE);主檔另有current_version_idsoft FK 回指從檔(查詢便利欄,非唯一性保證來源);主檔的分類三軸 soft-refdetection_profile_taxonomies.key(不建 FK);從檔file_idsoft-refupload_files;agent_tasks.params.profile存的profile:<uid>指的是從檔 uid。
9.3 核心表欄位(2026-08-03 DEV 實查)¶
config.detection_profiles — 檢測基準主檔(一列=一支基準)¶
欄位歸屬判準一句話:「換一版會不會變」。不會變的進主檔。
| 欄位 | 型別 | 說明 |
|---|---|---|
| id / uid | bigserial / varchar(36) NOT NULL UNIQUE | 主鍵+對外識別。🔴 主檔 uid 是拆表時新生的——舊表那 13 個 uid 逐列給了從檔 |
| scope | varchar(20) NOT NULL default 'TENANT' |
SYSTEM(公用版,owner 掛 root 租戶)/ TENANT(租戶自有)。CHECK chk_dp_scope;tenant_id IS NULL 永遠是壞資料(SYSTEM 列掛 root,不是 NULL) |
| tenant_id / org_unit_id | bigint NOT NULL / bigint | 擁有者租戶(TenantScopedMixinModel 自動填);org_unit_id 預留部門層級,本版邏輯只做租戶層 |
| detection_tool_id | bigint NOT NULL | soft-ref config.detection_tools.id——分池綁定。放主檔而非從檔:「這支基準給哪個工具用」不會因為換一版而改變 |
| name / description | varchar(255) NOT NULL / text | 基準名稱 / 描述。唯一索引約束的是「基準」不是「某一版」,改名不再撞版本鏈 |
| target_type | varchar(50) | 🆕 分類軸①標的類型:存字典 key 字串(soft-ref,刻意不加 FK);顯示名稱由前端 i18n 翻譯 |
| target_product | varchar(255) | 🆕 分類軸②標的產品:自由文字 |
| benchmark_family | varchar(50) | 🆕 分類軸③基準體系:存字典 key 字串,同 target_type |
| is_active | boolean NOT NULL default TRUE | 軟刪旗標,只放主檔=停用整支基準 |
| current_version_id | bigint | soft FK → detection_profile_versions.id,查詢便利欄。🔴 唯一性保證來源是從檔的 partial unique index uq_dpv_profile_current 不是這個外鍵;IS NULL 是合法狀態(整支停用過、無現行版) |
| created_user / updated_user / created_at / updated_at | varchar(255) ×2 / timestamptz ×2 | 稽核欄位 |
唯一索引:uq_dp_tool_tenant_name (detection_tool_id, tenant_id, name) WHERE is_active——一租戶一工具下不該有兩支同名基準。🔴 partial WHERE is_active 條件是「停用=讓出名字」語意的延續(CM-1047 於 2026-08-03 當天才在舊表修好「停用後同名再建撞 409」;拆表時若寫成無條件唯一,等於把剛修好的問題原地復活)。
拆表後不再需要舊模型那套「三組唯一索引+新建版號續號」的補償設計:舊索引拿
name當身分,停用列會佔名也佔版號;新模型的名稱唯一性收在主檔、版號唯一性收在從檔(profile_id, version),兩者徹底解耦。
查詢索引:idx_dp_tenant_scope(列表)、idx_dp_tool_active、idx_dp_taxonomy (target_type, benchmark_family)(分類篩選)。
config.detection_profile_versions — 版本從檔(一列=一版)¶
| 欄位 | 型別 | 說明 |
|---|---|---|
| id / uid | bigserial / varchar(36) NOT NULL UNIQUE | 🔴 uid 逐列沿用拆表前舊表的值——這是凍結契約:job_execution_detection_tools.tool_params 的 profile:<uid> 與 agent_tasks.params._profile 硬指著它,沿用=既有引用零轉換。語意也對,派工綁的本來就是「某一版」 |
| profile_id | bigint NOT NULL FK → detection_profiles(id) ON DELETE CASCADE |
所屬基準 |
| scope / tenant_id / org_unit_id | varchar(20) NOT NULL / bigint NOT NULL / bigint | 🔴 RLS 冗餘欄,與主檔同值——PostgreSQL 的 RLS 不沿外鍵繼承,且本表會被直接查(派工主幹道 _load_profile() 拿著一個 uid 就能查、不經過主檔)。漏掛的話租戶 A 能列出租戶 B 的全部 sha256 / file_id / url,而 file_id 洩漏搭配取檔通道就是一條實質的檔案越權路徑。改主檔 scope / tenant_id 時要同步更新從檔 |
| source_type | varchar(10) NOT NULL | file / url。CHECK chk_dpv_source_type;互斥 CHECK chk_dpv_source_exclusive(file 必有 file_id 無 url;url 反之) |
| file_id / url / sha256 | bigint / text / varchar(64) | soft-ref upload_files.id / 登記 URL / 對原始壓縮檔 bytes 的雜湊(agent 對帳信任根;⚠️ 與 CINC 自算的 profile 內容雜湊不是同一個值) |
| version / is_current | integer NOT NULL default 1 / boolean NOT NULL default TRUE | 版號 / 當前版。唯一性由 partial unique index 在 DB 層強制——「先降後插」的順序是被索引強制的,不是靠開發者自律 |
| supports | jsonb | 🆕 從該版 inspec.yml 抽取的執行平台(如 [{"platform-family":"linux"}]),唯讀不可編——它是事實不是分類,故不是分類第四軸 |
| extraction_status | varchar(20) NOT NULL default 'pending' |
🆕 抽取四態。CHECK chk_dpv_extraction_status (pending / running / succeeded / failed)。🔴 靜默失敗(exit 0、stderr 空、JSON 合法但 controls==[])也必須判 failed |
| extraction_error | text | 🆕 失敗訊息(stderr 截 2,000 字) |
| control_count / pending_count | integer ×2 | 🆕 該版控制項總數 / 人工待判數(統計索引,避免列表每次 COUNT(*))。抽取完成前為 NULL |
| source_etag | varchar(255) | 🆕 url 型:抽取當下來源回應的 ETag(強驗證器),開明細頁比對用。NULL=沒有基準可比 → 不判定(與空字串「伺服器回了空 header」要分得開,故不給 DEFAULT)。file 型恆 NULL |
| source_last_modified | varchar(128) | 🆕 url 型:抽取當下的 Last-Modified(弱驗證器,ETag 取不到時的後備)。同上。⚠️ 兩欄一律一起寫(含寫成 NULL),只更新其中一欄會留下前一輪的殘值 |
| created_user / updated_user / created_at / updated_at | varchar(255) ×2 / timestamptz ×2 | 稽核欄位 |
唯一索引兩組:
uq_dpv_profile_current(profile_id) WHERE is_current——「同一支基準只有一個當前版」由 DB 強制uq_dpv_profile_version(profile_id, version)——版本鏈完整性(版本無停用欄,故不加 partial 條件)
查詢索引:idx_dpv_profile / idx_dpv_tenant_scope / idx_dpv_file_id(partial NOT NULL)/ idx_dpv_extraction_status(抽取管線撈 pending / running)。
從檔第一期沒有版本級停用欄:現況無「停某一版留其他版」的需求證據,語意設計成本遠高於欄位成本。
config.detection_profile_controls — 控制項(一條一列)¶
正規欄只收「所有格式一定都有」的最小集合,格式差異全進 attributes jsonb。
| 欄位 | 型別 | 說明 |
|---|---|---|
| id / uid | bigserial / varchar(36) NOT NULL UNIQUE | 主鍵+對外識別 |
| version_id | bigint NOT NULL FK → detection_profile_versions(id) ON DELETE CASCADE |
所屬版本 |
| control_id | varchar(255) NOT NULL | 控制項編號。刻意不設 (version_id, control_id) 唯一約束——來源檔可能因 depends 等機制出現重複編號,硬約束會讓整支抽取失敗 |
| title / description | text ×2 | 標題 / 說明(取來源 descriptions.default;與它重複的 desc 欄位不落庫,連同丟棄 code 合計省 59.6% 體積) |
| severity_raw | varchar(50) | 原樣存來源值的文字形式。⚠️ 欄位刻意不叫 impact |
| severity_norm | varchar(20) | 跨工具可比的統一級距。刻意不加 CHECK 約束——級距字彙正是接第二個格式時最可能調整的東西,加 CHECK 等於把「加資料」降格成「改表」。寫入者只有各 extractor(程式),非使用者輸入 |
| is_pending | boolean NOT NULL default FALSE | 人工待判標記(判準寫在各 extractor 裡,不是表結構假設) |
| source_ref | text | 剝掉絕對路徑前綴後的相對路徑(controls/xxx.rb) |
| extraction_format | varchar(20) NOT NULL | 哪個格式抽出來的。刻意不加 CHECK:新格式=新增 extractor 類別+註冊一行,不該連帶改表 |
| origin | varchar(20) NOT NULL default 'extracted' |
extracted / custom。CHECK chk_dpc_origin。🔴 與 extraction_format 正交,兩者不可合併成一個欄位 |
| attributes | jsonb NOT NULL default '{}' |
格式專屬中繼資料整包(InSpec 放 tags + descriptions)。tag key 集合隨 profile 變動,schema 本來就不可寫死成固定欄位 |
| created_user / updated_user / created_at / updated_at | varchar(255) ×2 / timestamptz ×2 | 稽核欄位 |
索引:idx_dpc_version / idx_dpc_version_pending(partial WHERE is_pending)/ idx_dpc_control_id / GIN gin_dpc_attributes / btree 表達式 idx_dpc_attr_category (version_id, (attributes ->> 'category'))(GIN 幫不上 ->> 的等值/排序)。
🔴 本表不掛 RLS(刻意)。判準是「會不會被當作獨立查詢入口」:控制項永遠是被帶出來的——任何查詢都得先有
version_id,而version_id只能從已受 RLS 保護的從檔取得,沒有繞過從檔直接觸及控制項的路徑。專案既有慣例佐證:oscal.catalog_controls/oscal.poam_items/oscal.assessment_findings/survey.task_survey_ref_items等九張明細表零張掛 RLS,多半連tenant_id都沒有。
config.detection_profile_taxonomies — 分類 enum 全域字典¶
| 欄位 | 型別 | 說明 |
|---|---|---|
| id / uid | bigserial / varchar(36) NOT NULL UNIQUE | 主鍵+對外識別 |
| axis | varchar(30) NOT NULL | CHECK ck_dpt_axis 收在兩個值:target_type / benchmark_family(target_product 是自由文字不進表;執行平台 supports 是抽出來的事實也不進表) |
| key | varchar(50) NOT NULL | 🔴 唯一會被基準引用的值(soft-ref 非 FK),視為永久契約:只能新增不能改字面。CHECK ck_dpt_key_slug ^[a-z0-9][a-z0-9_]*$——擋掉大寫 / 連字號 / 空白,免得同一個概念因寫法不同變成兩個 key |
| sort_order | integer NOT NULL default 100 | 下拉與分組順序(小的在前)。「未分類」不是一列——它是基準的 NULL 值,由前端排在最後 |
| is_active | boolean NOT NULL default TRUE | FALSE=停用:不出現在新建/編輯選項,但既有引用仍解得出 key |
| created_user / updated_user / created_at / updated_at | varchar(255) ×2 / timestamptz ×2 | 稽核欄位 |
索引:uq_dpt_axis_key (axis, key) UNIQUE、idx_dpt_axis_sort (axis, sort_order, id)。不掛 RLS——分類骨幹由 root 統一維護、跨租戶共用,租戶不得增刪,否則篩選語意會漂移。
RLS:主從各四段共八段(detection_profiles_* + detection_profile_versions_*,policy 逐字對稱):SELECT 公用版分支放最前(人人可讀)+super_admin+自己子樹;INSERT WITH CHECK 堵 SYSTEM 偽造;UPDATE USING+WITH CHECK 兩段都有(缺 WITH CHECK 的話租戶可把自己的列 UPDATE 成 scope='SYSTEM' 竊升公用版);DELETE 非 super 不能刪 SYSTEM。app_tenant_allowed_for_session() 只接 integer,八處呼叫皆對 bigint tenant_id 顯式 ::integer cast。這是本專案首例主從雙表 RLS,migration 內含以 cm_app 身分實測的跨租戶驗證(偽造/竊升/改公版四路全部擋下)。
Migration:
| 檔 | 內容 |
|---|---|
scripts/sql/2026-08-03-fr060-1-detection-profile-split.sql |
三張新表 + 索引 + GRANT cm_app(表+sequence)+ 八段 RLS + 13 筆三段式搬遷(主檔 13 / 從檔 13 / current_version_id 非 NULL 12,一筆合法 NULL)+ 舊表 rename + 結構驗證(不過就整支 rollback) |
scripts/sql/2026-08-03-fr060-2-detection-profile-taxonomies.sql |
字典表 + seed 兩軸 13 個 key + 既有 13 筆分類補登(全檔 idempotent 可重跑) |
scripts/sql/2026-08-03-fr060-3-param-schema-extraction-declaration.sql |
param_schema 的 profile 欄位補 profile_format / supports_extraction 宣告(就地 UPDATE 不升版) |
scripts/sql/2026-08-04-fr060-3-dpv-source-validators.sql |
🆕 從檔加 source_etag(varchar 255)/ source_last_modified(varchar 128)兩個 nullable 欄位(CM-1075)。無預設、無索引;既有 13 筆不回填(理由見 §6.5) |
既有 13 筆的分類補登採「有把握的才填,推不出來留 NULL 不猜」:10 支 TWGCB-01-* → os + 名稱中明寫的產品名 + twgcb;2 支 google-chrome → browser + Google Chrome + twgcb;2 支 dev-sec → os + dev_sec 但 target_product 留 NULL(名稱只寫「適用 Linux/Windows 目標」,那是作業系統大類不是具體產品);TWGCB-Windows-2025 → os + twgcb 但 target_product 留 NULL(「2025」可能是 Windows Server 2025 也可能是年份)。結果:target_type / benchmark_family 各 13 筆全滿、target_product 10 筆,3 筆刻意留白。
四支皆只套 DEV,STG / POC 依環境異動鐵律等放行(§12 坑 15)。
使用判定另讀(不寫)兩張非本頁的表:
config.job_execution_detection_tools(任務綁定)與compliance.agent_tasks/compliance.detection_executions(派工單與執行歷史),以及project_extensions.deleted_at(軟刪穿透判準)。見 §9.1 補充與 §6.4。
10. 頁面邏輯與資料對應¶
載入時序:onMounted 先 Promise.all([fetchTools(), loadTaxonomy()])——工具目錄(所屬工具欄顯示名與新增下拉的來源;拿不到不擋列表,只是工具欄退成 code、新增下拉為空)與分類字典一起等,先畫再補對照會讓分類欄先閃一次原始 key 再變中文——之後才 fetchProfiles()。搜尋 300ms debounce 重查並跳回第 1 頁;scope / 工具 / 兩個分類篩選 / 停用開關任一變動同樣重查跳頁。分類字典走模組層快取(管理頁與 N 個任務抽屜同時掛載只打一次 API)。
關鍵欄位對應(畫面 ↔ API):
| 畫面元素 | FE state | API 欄位 |
|---|---|---|
| 列表 | profiles → displayProfiles(套「未分類」前端過濾)/ totalRecords |
POST /list 回 data / meta.total |
| 來源 Tag | 直接綁定 | scope |
| 所屬工具欄 | toolNameByCode[data.detection_tool_code] |
detection_tool_code+GET /detection-tools 對照 |
| 標的類型 / 基準體系欄 | taxonomyText(axis, key) → labelOf(三層 fallback) |
target_type / benchmark_family+GET /detection-profile-taxonomies 對照 |
| 標的產品欄 | 直接綁定(NULL 顯示 —) |
target_product |
| 當前版本欄 | data.current_version?.version |
current_version.version(null 顯示 —) |
| 狀態兩態 Tag | profileStatus(row, t) |
is_active |
| 操作選單項放不放 | fork 條件(scope+is_active)/ current_version 有無 |
scope+is_active+current_version |
| 操作選單項 disabled | canWriteProfile(row)(整列唯讀)+ canCreate / canUpdate / canDelete |
can_edit+is_active+登入者 permissions(hasCap) |
| 操作選單「刪除」vs「停用」 | actionMenuUsage(開選單前查、不 await) |
GET /<uid>/referencing-usage 的 in_use(未使用給刪除、已使用或未知給停用) |
| 「修正來源」是否 disabled | frozen ← usageReason(usage, t, 'profile') |
同上 in_use + canWriteProfile + canCreate |
| 操作選單頂端說明 | actionMenuReadonlyReason ← usageReason(...) 優先於 rowReadonlyReason(row) |
in_use + projects[].name(→使用中凍結文案,帶專案名)/ can_edit(→公版唯讀)/ is_active(→已停用) |
| 刪除確認框的「N 個版本、M 條控制項」 | loadVersions(row.uid)(版本子表同一份快取)→ 長度與 control_count 加總 |
GET /<uid>/versions(不是判定端點——它回的是「被誰用過」不是「有多少東西」) |
| 來源欄鎖 icon | scope === 'SYSTEM' && can_edit === false |
scope+can_edit |
| 展開列版本子表 | ProfileVersionTable(模組層快取,展開才打) |
GET /<uid>/versions |
| 全部展開/收合 | allExpanded(比對 expandedRows 鍵數與當前列數)/ toggleExpandAll |
純本地狀態,不打 API(刻意不 invalidate 版本快取) |
| 抽取狀態徽章 | extractionBadge(status, t, te) |
current_version.extraction_status 或版本列的同名欄位 |
| 詳細 Dialog | detailDialog.row(列表列原資料,不另打 API) |
同列表欄位(含 current_version.file_name / sha256 / url) |
| 產品 autocomplete 建議 | productSuggestions(從目前載入資料蒐集去重) |
現有列的 target_product |
| 更新者欄 | 直接綁定 | updated_user_name(fallback updated_user) |
控制項頁欄位對應:
| 畫面元素 | API 欄位 |
|---|---|
| 摘要三數字 | control_count / pending_count / 兩者相減=自動檢查 |
| 抽取狀態區 | extraction_status + extraction_error |
| url 型快照提示(三態) | source_type + extracted_at + source_outdated(🔴 三者只有 controls 端點會回,輪詢不可覆蓋) |
| 控制項表格 | controls[] 的 control_id / title / is_pending / severity_norm / source_ref |
| 展開列中繼資料 | attributes(動態走訪,不假設固定 key) |
儲存流程:新增依 source_type 分流——file 組 FormData(欄位逐一 append、空值不帶)、url 組 JSON(空值帶 null)→ createProfile() → 成功 toast+關 Dialog+清 FileUpload+跳回第 1 頁重查。編輯走 JSON updateProfile()。版更 / fork / 複製 / 停用 / 修正來源 / 刪除成功後重查當前頁;重查時收合全部展開列並清版本快取+清使用判定快取(列物件換了新 reference,且那些動作會改變版本組成,舊判定可能已不成立)。刪除吃到 409 時同樣清判定快取——判定與實際刪除之間可能有人剛好用了它,清掉後下次開選單重問,選單就會自動從「刪除」換成「停用」。
錯誤對應:BE error envelope → axios interceptor 依 error code 查 error-code.json 出 toast(碼表見 §6.4 / §6.6);Dialog 的 catch 留空不重複出錯。列表載入失敗另有本地 toast(toast_load_failed)並清空列表。
11. 背景行為與外部依賴¶
| 類型 | 內容 |
|---|---|
| 通知 | 無——本頁不觸發、不消費任何通知 |
| Socket | 無 socket。抽取進度靠前端 5 秒輪詢(不走 WebSocket,見 §6.5);其餘動作後重查 |
| 背景抽取 worker | 🆕 DetectionProfileExtractionService:Python threading 背景執行緒(非 Celery / 非排程 job),worker 自行 set_user_context() 還原並各自包 session_scope();長時間的 CINC 執行與 url 下載都在 session 之外。主機依賴:CINC Auditor 7.1.7(見 §6.5 與 docs/claude/host-dependencies.md) |
| 任務抽屜下拉動態化(本庫的消費端) | param_schema 的 profile 欄位帶 "options_source": "profile_library" 宣告(inspec / gcb 已升 v2 帶宣告並拿掉靜態 options,migration 2026-08-01-fr059-3-param-schema-options-source.sql)→ FE DetectionConfigField 偵測宣告改呼 menu API(帶工具 uid 分池)動態組選項;🆕 選項按分類分組呈現(groupByAxis),editable 手填能力保留。機制 tool-agnostic,新工具 seed 宣告即接庫、FE 零改。詳見 任務配置 §5 |
派工展開(profile:<uid> → _profile) |
使用者選庫內基準發起執行時,params.profile 存 profile:<uid>(uid=版本 uid);派工當下(非心跳組裝時)展開 _profile 內部 payload——file 型 {uid(=upload_files uid), version, sha256, source_type, file_name}、url 型 {uid, source_type, url}。params.profile 原值保留(FE 執行紀錄顯示用);_profile 底線前綴被既有剝除機制擋掉不出 API(同 _credentials 慣例)。參照失效(查無 / 所屬基準已停用)→ 400 明確報錯不靜默;is_current 不檢查——歷史版本照使用者所選執行,凍結快照(version / sha256)正是稽核回溯要的。🔴 拆表對這條鏈零影響——從檔 uid 逐列沿用舊值,既有引用不必轉換 |
| Agent 取檔授權 | profile_ref resolver(app/detection_tools/service/agent_file_access_service.py):uid 是該 agent 名下 pending / running 任務 params._profile 引用的檔 → 放行 mTLS 取檔端點 GET /api/1.0/agents/files/<uid>(FR-058.7 通道,可插拔 resolver 清單) |
| Agent 端 cache | evidence-agent core/profile_cache.py+inspec.py::_resolve_profile():file 型查 /data/content/cache/<uid>/v<version>/ 命中直用;miss → 拉檔 → sha256 對帳(不符即 fail)→ 安全解壓(agent 端同防 slip / bomb)→ 解壓後目錄餵 cinc-auditor(四格式一條路徑)。url 型 / 手填原樣入 argv(FR-060 未觸及掃描路徑,行為零變化)。cache key 含 version → 版更自然失效;第一期無上限+手動清理 |
| 執行紀錄標籤對照 | JobExecutionDrawer.vue:282 fetchProfileLibraryLabels:宣告欄位的值→顯示名對照另向 menu API 拿(param_schema 已無靜態 options),否則執行紀錄會露出 profile:<uuid> 裸值;拿不到退回顯示原值不擋畫面。🔴 它的實作是 for (const m of items) map[m.value] = m.name,這正是 menu API 必須維持扁平的原因(§6.2) |
| 搬遷 seed | scripts/seed_2026-08-01_fr059_detection_profiles.py——8 支 TWGCB 打包走與使用者相同的上傳管線落 SYSTEM file 型+dev-sec linux / windows 基準 2 條落 SYSTEM url 型(名稱沿用原靜態選項 label,下拉零回歸);DEV 現況共 13 支基準(公用版 12 + 租戶自傳 1) |
| jedi-* 套件 | 無新增依賴(@transaction / BaseRepositoryImpl / exception 為既有 jedi_common) |
| 系統參數 | 無 feature flag;上傳驗證上限(50MB / 500MB / 10,000 entry / 比 200)為 ProfileArchiveValidator 建構參數預設值,DI 可帶 config 覆寫;🆕 CINC 執行逾時預設 900 秒、extraction_error 截斷 2,000 字;CINC binary 路徑可用 CINC_AUDITOR_CMD 環境變數覆寫 |
12. 邊界情況與已知坑¶
- 🔴
current_version可以是null,列表必須渲染得出來:整支停用過的殘留主檔就是這個形狀(DEV 有一筆)。版本欄顯示—、操作選單裡的「檢視控制項」與「版更」兩個項目整項不放(給一個點了會 404 的選項比不給更糟)。⚠️ 注意這裡是「不放」不是 disabled——CM-1078 之後選單的通則是「不能做的留著 disabled 並說明原因」,但這兩項是「這一列語意上根本沒有這個動作」(沒有當前版就沒有控制項可看、沒有來源類型可沿用),與「動作存在只是你不能做」是兩回事,兩者不可對齊成同一套。同時它不進任務下拉——派工綁的是某一版,沒有版可綁就不該出現在選單裡。任何假設「一定有當前版」的程式碼都會在這筆資料上壞掉。 - 分類字典的維護 UI 尚未實作(現況):BE 四支端點(新增 / 排序停用 / 刪除 / 引用數)與前端
DetectionProfileTaxonomyService的四個方法都已就位,但沒有任何 view 消費寫入端(實查grep -rn "createTaxonomy\|updateTaxonomy\|deleteTaxonomy" src/views/零命中)。現況新增分類值走 migration。要補 UI 時 BE 與 service 層都是現成的,只缺一個 root 專用的管理畫面。這一條是刻意寫進來的:FR-059 曾預留
field_scope/hint_scope_system兩個 i18n key 卻沒做 UI,後來沒人知道那兩個 key 為什麼在那裡(FR-060 D18 才把它們接起來,root 建公版現在有 UI 了)。不要製造第二個懸空孤兒——此坑的存在就是為了讓下一個人知道那四支端點不是死碼。 detection-profile.updatecapability 從 FR-060 起才有消費者:FR-059 時期四能力點全數 seed,但版更 / fork / 複製全是 INSERT 對create、停用對delete,update沒有任何端點在用(當時 spec 稱它「預留孤兒」)。FR-060 的PUT /detection-tool-profiles/<uid>消費了它,權限矩陣不再有一列是死的。對角色勾 update 現在有實際效果——沒勾的人看得到編輯鈕但按不動(disabled)。- 讀端點僅 JWT 無 capability 守門:列表 / menu / 詳細 / 版本歷史 / 控制項 / 抽取狀態 / 分類字典讀取都只
@jwt_required()——menu 是任務抽屜全員要用的(刻意開放),其餘讀取端跟著同粒度。可見範圍靠查詢收斂+RLS,不靠 capability;與寫入端點的守門粒度不一致,改動時勿混為一談(同 檢測工具管理 §12 坑 2 模式)。 - 「未分類」篩選在前端做,分頁筆數會與
meta.total不一致:選了未分類時 FE 用 sentinel 值在本地過濾(BE 不知道我們又濾掉了一些),列表下方會出現提示。可接受的原因:未分類是待補的例外狀態、筆數本來就少,而讓 BE 支援「篩 NULL」要在 filter schema 上發明一個 sentinel 值——同樣的複雜度搬到契約層,而且會永久留在 API 表面。若未來未分類變成常態量級再回頭讓 BE 支援。 - fork 與複製到其他工具共用檔案實體不複製 binary:
file_id/sha256沿用來源版——profile 檔不可變(版更=新列新檔),同內容存兩次沒有意義。含意:來源公用版停用不影響 fork 列(upload_files實體仍在);反向也一樣。 - fork 不走 SYSTEM guard、複製到其他工具走:fork 寫的是新租戶列(scope 硬寫 TENANT);複製沿用來源 scope——公用版複製到另一池結果仍是公用版,故需平台管理員。兩者語意差異就在這裡,改動守門時別對齊成同一套。
- 派工不檢查
is_current只檢查所屬基準的is_active:任務綁定存的是使用者當初選的那一版 uid,版更後舊版仍是合法歷史版本,掃描照使用者所選執行(要換版是使用者自己回任務改選,不是 BE 偷偷跳版);停用才是「不該再被使用」的管理決策。參照失效(查無 / 停用)→ 派工當下 400 明確報錯,不原樣下發(agent 會拿到無法解析的profile:<uid>變成 agent 端神祕失敗)、不默默拿掉(使用者以為在掃 A 基準實際參數沒了)。 profile:前綴契約單點:menu 的value已是 BE 組好的profile:<uid>,FE 不自己拼前綴;BE 端前綴判斷只有common/util/detection_profile_ref.py一份,派工展開不可自己寫startswith。手填值不可能以profile:開頭——這是三種取值來源(庫內 / 手填 / 舊靜態選項值)零歧義共存的基礎。- 🔴 三種 uid 不可混用:① 主檔 uid——列表列的
uid,基準的識別,編輯 / 版更 / fork / 停用端點收它;② 版本 uid——current_version.uid與版本子表列的uid,profile:<uid>契約指的就是它,控制項與抽取端點(/detection-tool-profile-versions/<uid>/…)收它;③upload_filesuid——_profile.uid對 file 型存的是這個(agent 拿它直接打取檔端點,該端點認的就是 upload_files uid),url 型無檔案實體時_profile.uid才是版本 uid。傳錯會 404 而錯誤訊息只會說「找不到」,所以控制項頁的入口一律從列表的current_version.uid或版本子表列的uid帶進來,不要手拼。 - 上傳驗證是第一道不是唯一一道:bomb 檢查靠 entry 自報的宣告 size(header 值理論上可偽造)——偽造後 agent 端真解壓時與宣告不符會失敗,且 agent 端另有一套同等的 slip / bomb 防護(BE 驗過不代表可信任傳輸後內容)。本驗證器是全 repo 第一個壓縮檔安全實作,之後任何要收壓縮檔的功能應來
common/util/detection_profile_archive.py復用,不要各自再寫一套。 - 「掃描不代管 url」與「抽取代為下載」是兩件事,別對齊成同一套:掃描路徑——agent 原樣把 URL 交給檢測引擎自行下載,平台不驗證可達性、不代管憑證(此界線在設計即明寫,含 FE hint 文案,別當缺陷回報)。抽取路徑——BE 代為下載到記憶體解析(SSRF 五道防護),但不落儲存、不改
source_type、file_id/sha256維持 NULL。存成 file 型快照會讓 url 型悄悄變成 file 型、破壞雙軌語意,且掃描(agent 拉 URL)與抽取(平台存快照)從此可能對不上,對不上時沒人知道該信哪個。附帶含意:url 型抽出來的是某一刻的快照(masterbranch 會漂)、沒有 sha256 當信任根,前端明示給使用者。 - 🔴
severity_norm的none是「不做自動判定」不是「風險低」:InSpec 的impact 0.0表示這條需人工判讀,不是低風險。前端因此不把none畫成 low 的顏色而走中性灰底的「不判定」——否則 199 條人工待判項會被誤讀成 199 個低風險項。同理嚴重度欄位刻意不叫impact(那是 InSpec 專有的 0.0–1.0 浮點語意,XCCDF 是五級列舉,寫死會讓第二個格式進來時只剩改表一條路)。 - 抽取的
pending是「還沒觸發過」不是失敗:空狀態文案不可寫成「抽取失敗」或「這份基準沒有內容」——DEV 現況多數版本就停在這個狀態(拆表搬遷進來的既有資料沒有跑過抽取)。寫成失敗會讓使用者去追一個不存在的問題。同理control_count的 null 與 0 是兩件事(null=還沒抽完;0=抽到了但真的沒有)。 - ⚠️ 本功能現況只在 DEV:三張新表 / 分類字典 / capability / 選單路由 / param_schema 宣告 / 搬遷 seed 全數只套 DEV(環境異動鐵律)。param_schema 相關 migration 尤其不可單獨誤套 STG / POC——FR-059 那支拿掉靜態 options,而該兩環境沒有搬遷 seed 資料,套下去任務抽屜下拉會直接空掉;必須整批依序等決策者放行。另外 STG / POC 主機尚未安裝 CINC Auditor(安裝等放行)——未裝的環境上傳與掃描都正常,只有抽取會落 failed。
- 同名檢查是業務層前置、唯一索引是兜底:
find_by_exact_name查同租戶同工具的名稱;公用版與某租戶自有版可以同名(不同tenant_id池)——fork 預設沿用原名能成立正是因為這點。改名時「改成自己現在的名字」不算撞名,故 service 先比對再查。 - 「停用=讓出名字」的語意由主檔 partial 唯一索引維持:
uq_dp_tool_tenant_name帶WHERE is_active,停用列不再佔名。這是 CM-1047 在舊表修好的行為(2026-08-03 當天),拆表時刻意延續——若日後把 partial 條件拿掉,「停用後同名再建撞 409」會原地復活。 > 拆表廢掉了舊模型那套配套補償:舊表拿name當身分,需要三組唯一索引+「新建版號從同名is_active列 max(version)+1 續號」才能維持這個語意;新模型名稱唯一性收在主檔、版號唯一性收在從檔(profile_id, version),兩者解耦,續號規則整個消失。看到舊文件講「三組索引」「起始版號續號」時知道那是拆表前的事。 - 🔴 「版本層可刪、主檔層不可刪」是合法狀態不是矛盾:版本層判定穿透軟刪專案(那些引用不計入)、主檔層不穿透,兩者嚴格度刻意不同——因為兩種刪除的破壞半徑不同(刪一版只掉那一版的控制項;刪主檔連帶清掉所有版本與控制項,而任務可能已刪、報告與證據還在)。看到同一支基準「某一版可以刪、整支不能刪」時不要當成 bug 去追。同理主檔層會出現
in_use=true但projects為空的合法狀態(引用全在使用者看不到的軟刪專案裡),組訊息不可假設非空——畫面上出現空括號就是這裡漏判。 - 🔴 「用過即凍結」沒有後門,也不該加:修正來源 / 刪除三支方法的簽章不得出現
force/confirm/override(有測試專門釘住這件事)。理由是稽核可信度而非 UX 保守:版本 uid 是派工契約、sha256是 agent 對帳基準,同一個 uid 在不同時間指向不同內容,日後回頭看掃描報告沒人能確定當時掃了什麼。日後若有人提「加個確認框讓管理員硬改」,那正是這條要擋的東西——出路是建立新版本(版號本來就是為此存在)。 - 使用判定漏一個引用來源=靜默放行刪除:判定必須同時看三個階段(任務已綁定 / 已派工單 / 已有執行歷史),且 ②③ 要加
source_type='url'(file 型的_profile.uid是upload_filesuid,不加會永遠比不中)、jsonb 存在判斷要用jsonb_exists()不可用LIKE '%_profile%'(_是 LIKE 的單字元萬用字元,DEV 實測錯誤寫法回 32 筆、正確 7 筆)。守門一律委派 canonical 的_version_usage()/_profile_usage(),不在守門處重查、也不繞過去直接用 infra 的 query class——只能有一份真相。 - 刪除是硬刪,
ON DELETE SET NULL不是業務規則:current_version_id的 FK 雖是 SET NULL,那是防資料損毀的最後一道;「當前版不可單獨刪」擋在業務層(409011),因為指標懸空後派工就拿不到可綁的版本。同理連帶刪除靠的是兩層ON DELETE CASCADE,這代表刪主檔不可逆、沒有任何軟刪痕跡可回溯——與「停用」的差別必須在 UI 上講清楚(見 UC-DPM-07)。 - url 型過期偵測:判太鬆比判太嚴更糟:探測不到驗證器、探測失敗、來源不給 ETag——一律當「未變更」。判成有變更的代價是那類來源每次開頁重跑一次 CINC(234 條 28 秒、708 條 117 秒),而且症狀是靜默的(畫面一切正常,只有主機在燒)。另兩個容易踩的點:①
Accept-Encoding必須釘死identity(ETag 隨編碼協商而變,httpx 預設送 gzip,探測與下載不一致就永遠比不相等);② 驗證器只在抽取成功時落庫、兩欄一起寫(失敗也記等於那一版永遠停在失敗態且無自動復原路徑)。 - 一般
succeeded(未過期)態目前沒有重抽入口:BE 端點POST /detection-tool-profile-versions/<uid>/extraction一直存在,但控制項頁只在failed/pending/ url 型偵測到過期三種情況顯示「重新解析」按鈕——沒過期時給按鈕等於鼓勵使用者對著沒變的來源重跑 CINC。這是刻意的現況,不是漏做;要重抽一份未過期的快照目前只能改來源或版更。 - 舊表
detection_tool_profiles已 rename 不可再引用:現名detection_tool_profiles_deprecated_20260803,未 DROP(觀察期後另開 migration,DROP 前需比照「DROP 退役表前安全四查」實際 grep 確認沒有 live-wired 的 code 還在引用)。platform_hint的值只留在這張退役表裡,不搬新表也不在新表佔位。任何新程式碼引用舊表名都是錯的。
13. 開發與驗證¶
- 跑起來:BE
python main_app.py(port 8000,log 在log/app.log);FE 在compliance-manager-fe/起 Vite dev server。BE 改 service code 後必須重啟 - 主機依賴:抽取功能需本機裝 CINC Auditor(
curl -fsSL https://omnitruck.cinc.sh/install.sh | bash -s -- -P cinc-auditor -v 7)。一行檢查which cinc-auditor && cinc-auditor version;裝在非標準路徑時設CINC_AUDITOR_CMD指向絕對路徑。⚖️ 絕不可換成官方 InSpec 6+ 商業 binary 或inspec-coregem(Chef EULA)。缺了只影響抽取,上傳與掃描照常。詳見docs/claude/host-dependencies.md§③ - 測試帳號:dev 環境持
detection-profile.create/update/deletecapability 的帳號(比照流程範本管理授予的租戶系統管理員角色;公用版與分類字典維護需 root 租戶平台管理員),密碼見.env/ 部署文件 - 導航路徑:登入 → 合規稽核 → 「掃描設定檔管理」(
/plugin/detection-profile-manage,選單掛在檢測工具管理與流程範本管理之間);控制項頁從列操作選單的「檢視控制項」(現行版)或版本子表 kebab 的同名項(指定版)進入 - 前置資料:
config.detection_tools已 seed(工具下拉來源);DEV 現況 13 支基準(公用版 12 + 租戶自傳 1),由scripts/seed_2026-08-01_fr059_detection_profiles.py落庫後經 FR-060 拆表搬遷;分類字典 13 個 key 由2026-08-03-fr060-2-*.sqlseed;上傳測試可用content/detection-profiles/下任一 TWGCB 目錄自行打包 tar.gz - 驗證要點:
- 上傳四格式正常樣本全過、惡意樣本(假副檔名 / 超大小 / traversal / 無 inspec.yml)各回獨立 error code
- 改名不升版:編輯後版本子表版號與筆數不變、
current_version.uid不變(拆表前這是做不到的) - 改名撞名擋下、改成自己現在的名字不算撞名
- 分類清空:把已填的分類送空字串,重整後確實變回「未分類」(不是靜默失效)
- 版更後 menu 指新版且舊版留存於展開列;fork 後副本可編輯;非 root 對公用版寫入 403;租戶 A 看不到租戶 B 自有列(RLS,主從兩張表都要驗)
- 列操作選單:以非平台管理員開一列公用版 → 選單頂端出現唯讀說明、編輯 / 版更 / 複製到其他工具 / 停用皆 disabled,但「複製為自有」仍可按且按下去真的建得出副本;已停用列的說明文案是「已停用的設定檔不可再異動」;無現行版的那筆殘留主檔沒有「檢視控制項」與「版更」兩項(是不放不是灰的);把某個 capability 拔掉後對應項 label 後綴「(無權限)」——且整列唯讀時不重複後綴
- 全部展開/收合:管理頁按下攤開當前頁全部列(觀察 network 不應打出 N 個 versions 請求,快取命中即可)、再按收合;控制項頁先篩到一小組再按,展開的只有篩到的那些(切換篩選會清空展開狀態);兩頁列數為 0 時按鈕 disabled
- 展開列降階:亮色與暗色主題各看一次——展開區塊要讀得出是「掛在上一列底下的附屬內容」(凹陷底色+左側色條+標題列),亮色主題尤其要確認暖色遮罩有把底色壓出層次而非與卡片同色
- 任務抽屜 GCB 池只見 gcb、CINC 只見 inspec,且選項按分類分組、手填仍可用
- 抽取:上傳一支 234 條的 profile,狀態走
pending→running→succeeded(約 30 秒),三個數字與控制項清單正確;故意上傳一支.rb有 Ruby 語法錯的(CINC 會 exit 0 但 controls 空)必須判 failed 不可顯示成「這支基準沒有控制項」;停掉 CINC(或把CINC_AUDITOR_CMD指到不存在的路徑)驗證錯誤訊息可讀且上傳仍成功 - 使用判定與凍結:拿一支從未被使用過的基準開操作選單 → 出現「刪除」(不是停用)、「修正來源」可按;拿一支已被掃描任務用過的 → 出現「停用」(刪除整項不出現,不是灰的)、「修正來源」灰掉、選單頂端說明帶得出擋住它的專案名。判定端點打不通時(斷網或改錯 URL)走保守路徑:仍給停用、修正來源灰掉並說「暫時無法判定」,絕不可變成可刪
- 修正來源:對未使用的版本改 url(或 file ↔ url 互換)→ 版號不變、
current_version.uid不變、抽取狀態回落pending並自動重抽;改完舊控制項清空、supports/control_count/source_etag一併歸零。對已使用過的版本送 PATCH → 409DETECTION_TOOLS_409009 - 刪除(硬刪):刪未使用的主檔 → 主檔 / 版本 / 控制項經 CASCADE 全數消失(DB 實查而非只看畫面),確認框的「N 個版本、M 條控制項」與實際筆數相符;刪已使用的主檔 → 409
DETECTION_TOOLS_409010;刪當前版 → 409DETECTION_TOOLS_409011 - 軟刪穿透兩層嚴格度:找一支引用只存在於已軟刪專案的版本 → 版本層可刪、主檔層擋下(這是合法狀態,見 §12 坑 18);擋下訊息的
projects為空時不可出現空括號 - 無 force 後門:
update_version_source/delete_version/delete_profile三支方法的簽章不得出現force/confirm/override(有測試釘住) - url 型來源過期偵測:① file 型開明細頁不探測(觀察 log 無 HEAD);② 未記錄過驗證器的 url 型不探測;③ 來源未變 → 不重抽(log 確認排程次數未增加)、載入耗時與 file 型同級;④ 模擬上游變更 → 判過期+自動重抽+ETag 收斂回真實值+提示自動消失;⑤ 公版遇上非平台管理員 → 判過期但不排 worker、狀態未被污染,畫面走靜態提示+「重新解析」鈕(不可是轉圈)。⚠️ ④ 需要上游內容真的變動才觸發,實務上以單元測試涵蓋
- 分頁:預設每頁 20 筆,每頁筆數可切 10 / 20 / 50
- 分類字典刪除保護:對已被引用的 key 送 DELETE → 409 且訊息帶引用數;停用該 key 後既有基準的分類仍顯示得出來
- i18n 降級:直接對 DB 塞一個前端沒有 i18n 條目的 key,畫面應顯示原始 slug 而非空白或報錯
- E2E:
compliance-manager-test/repo(Cucumber + Playwright);以detection-profile/掃描設定檔搜尋 - 相關文件:FR-060 設計決策全文(D1–D19、三張表欄位定義、抽取器註冊表、RLS 兩表八段)
docs/features/FR-060-2608-detection-profile-content-management/design.md;前身 FR-059 的決策脈絡docs/features/FR-059-2608-detection-profile-library/design.md與discussion.html;下拉動態化與三層取值來源見 任務配置;執行紀錄的 profile 標籤對照見 我的任務 §11.1;工具目錄與 param_schema 兩層 schema 關係見 檢測工具管理;主機依賴見docs/claude/host-dependencies.md;使用手冊docs/user-manual/scan-profile-guide.md