跳轉到

掃描設定檔管理(Scan Profile Management)

功能群:系統管理|共用權限模型先讀 功能群總覽

事實基準:2026-08-04 從 FE src/views/detection-profile/DetectionProfileManageView.vuecomponents/ 六支(ProfileFormDialog / ProfileNewVersionDialog / ProfileVersionTable / ProfileDetailDialog / ProfileCopyDialog / ProfileSourceDialog)+ profileDisplay.jsDetectionProfileControlsView.vuecomponents/ControlExtractionSummary.vuecomposables/useProfileTaxonomy.js / useProfileUsage.js、BE api/detection_tools/routes/detection_profile_route.py / detection_profile_taxonomy_route.pyapp/detection_tools/service/detection_profile_service.py / detection_profile_taxonomy_service.py / detection_profile_extraction_service.py 全鏈 + DEV config.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-startflex-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),不驗可達性

UC-DPM-01 流程:開 Dialog 選工具與來源類型 → 檔案型 multipart / 連結型 JSON → capability 與同名檢查 → 檔案型驗證管線(格式/大小/安全/結構四碼分立)→ sha256 → 存儲 → INSERT version=1

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-02 流程:按版更 → BE 檢查公用版寫入權 → 判斷是否帶新來源(帶了重跑驗證管線、未帶沿用舊版來源與 sha256)→ 先降舊版 is_current 再 INSERT version+1 → 下拉即指新版

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_editis_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、service detection_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 四欄單次寫定;🔴 versionis_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_idurl 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_executionsagent_task_uid → ② 已有執行歷史

⚠️ ②③ 必須加 source_type='url' 條件:_profile payload 的 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=trueprojects 為空的合法狀態(引用全在已軟刪專案裡),組訊息不可假設非空(畫面上不能出現空括號)。

頁面 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:43rowReadonlyReason: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:342buildActionMenuItems:345-421
使用判定 in_use === true(已被使用過) 🔴 「修正來源」disabled(動作存在、只是這一列不能做,拿掉會讓使用者不知道有這條路)+ 「刪除」整項不放、改出現「停用」(兩者互斥只出現一個)。原因由選單頂端說明列講,且帶得出擋住它的專案名稱 useProfileUsage.js::usageReasonbuildActionMenuItems(主列表)/ ProfileVersionTable.vue::actionMenuItems(版本子表)
使用判定尚未回來 / 查不到usage === nullunknown 安全預設:給「停用」、「修正來源」disabled。🔴 判定失敗時保守回「無法判定」而不是「未使用」——判定回錯方向會放行不該放行的刪除 useProfileUsage.js::UNKNOWNin_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:247DetectionProfileControlsView.vue::allExpanded:377 / :381
extraction_status ∈ {pending, running} 控制項頁開 5 秒輪詢(只打最小狀態端點);轉 succeeded 才重載一次完整清單;離開頁面 / 狀態收斂 / 元件卸載都停掉計時器 DetectionProfileControlsView.vue 檔頭「輪詢」段

5. UI 設計

版面骨架(篩選列 + DataTable + 四組 Dialog):

掃描設定檔管理版面:標題+新增鈕、來源範圍 pill+工具下拉+搜尋+歷史開關篩選列、公用版排前的分頁列表(名稱/來源/工具/類型/版本/狀態/操作)、新增與版更與 fork 與複製 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 掛在項目上根本觸發不了。訊息三要素缺一不可——被幾個專案用 + 被哪些專案用 + 該怎麼辦(只寫「已被使用不可修改」不合格);⚠️ 那個數字是專案數不是掃描任務數(判定端點回的是 projectsversion_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:「來源網址的內容已經更新,以下顯示的是先前的快照…」+「重新解析」按鈕。🔴 不可改成轉圈,那個圈永遠不會完成

提示條的邊框走 currentColorcolor-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 ——主檔資源(一支基準)。🔴 拆表是內部資料模型變更,對外路徑刻意不改名(FE api.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_onlysource_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_versionDetectionProfileVersionSchema,同一份 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:282for (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-51multipart/form-datarequest.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_400015target_product 是自由文字不驗
scope string 預設 TENANTSYSTEM 僅平台管理員送得動(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_uidsourcesjob_binding / agent_task / execution

實作落點刻意是獨立 query classinfra/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 / nameversion / 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 的 @transaction commit 之後排。
  • 必須非同步:實測 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-core gem(受 Chef EULA)。詳見 docs/claude/host-dependencies.md §③。
  • 缺 CINC 的影響面:基準上傳/版更仍然成功,派工掃描完全不受影響(agent 吃的是壓縮檔本身,不是抽出來的控制項);只有抽取落 failed,錯誤訊息為「找不到 cinc-auditor 執行檔…」。
  • worker 紀律:新執行緒的 ContextVarSessionLocal 都是空的,呼叫端捕捉 user context、worker 內還原並各自包 session_scope();長時間的 CINC 執行刻意放在 session_scope() 之外(三段式:讀(短)→ 抽(長,無 session)→ 寫(短)),否則一次上傳會吃掉一條 DB 連線兩分鐘。最外層一律 catch Exceptionfailed——漏接會讓那一版永遠卡在 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_filessource_type 維持 urlfile_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())對來源發一次輕量 HEADprobe_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 仍是 urlfile_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 放 tagsdescriptions)。⚠️ key 集合隨 profile 變動(實測 TWGCB-01-01101-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 受控 enumos 作業系統/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 受控 enumtwgcb / 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 後綴缺權限標記、openActionMenunextTick 等 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、optionsOfgroupByAxis
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_allversions_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)——必須在 @transaction commit 之後才排,否則 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:detection_tool_profiles 以 detection_tool_id soft-ref 工具目錄、file_id soft-ref upload_files;param_schemas 以 options_source 宣告接庫;派工時 agent_tasks.params 內嵌 _profile 快照

⚠️ 此 ER 圖畫的是 FR-059 的單表模型,FR-060 拆表後與現況有落差,待重繪。拆表後的關係一句話:detection_profiles(主檔)1─N detection_profile_versions(從檔,FK + ON DELETE CASCADE)1─N detection_profile_controls(控制項,FK + CASCADE);主檔另有 current_version_id soft FK 回指從檔(查詢便利欄,非唯一性保證來源);主檔的分類三軸 soft-ref detection_profile_taxonomies.key不建 FK);從檔 file_id soft-ref upload_filesagent_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_scopetenant_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_activeidx_dp_taxonomy (target_type, benchmark_family)(分類篩選)。

config.detection_profile_versions — 版本從檔(一列=一版)

欄位 型別 說明
id / uid bigserial / varchar(36) NOT NULL UNIQUE 🔴 uid 逐列沿用拆表前舊表的值——這是凍結契約job_execution_detection_tools.tool_paramsprofile:<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 稽核欄位

唯一索引兩組

  1. uq_dpv_profile_current (profile_id) WHERE is_current——「同一支基準只有一個當前版」由 DB 強制
  2. 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 放 tagsdescriptions)。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_familytarget_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 → osdev_sectarget_product 留 NULL(名稱只寫「適用 Linux/Windows 目標」,那是作業系統大類不是具體產品);TWGCB-Windows-2025ostwgcbtarget_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. 頁面邏輯與資料對應

載入時序onMountedPromise.all([fetchTools(), loadTaxonomy()])——工具目錄(所屬工具欄顯示名與新增下拉的來源;拿不到不擋列表,只是工具欄退成 code、新增下拉為空)與分類字典一起等,先畫再補對照會讓分類欄先閃一次原始 key 再變中文——之後才 fetchProfiles()。搜尋 300ms debounce 重查並跳回第 1 頁;scope / 工具 / 兩個分類篩選 / 停用開關任一變動同樣重查跳頁。分類字典走模組層快取(管理頁與 N 個任務抽屜同時掛載只打一次 API)。

關鍵欄位對應(畫面 ↔ API):

畫面元素 FE state API 欄位
列表 profilesdisplayProfiles(套「未分類」前端過濾)/ totalRecords POST /listdata / meta.total
來源 Tag 直接綁定 scope
所屬工具欄 toolNameByCode[data.detection_tool_code] detection_tool_codeGET /detection-tools 對照
標的類型 / 基準體系欄 taxonomyText(axis, key)labelOf(三層 fallback) target_type / benchmark_familyGET /detection-profile-taxonomies 對照
標的產品欄 直接綁定(NULL 顯示 target_product
當前版本欄 data.current_version?.version current_version.version(null 顯示
狀態兩態 Tag profileStatus(row, t) is_active
操作選單項放不放 fork 條件(scopeis_active)/ current_version 有無 scopeis_activecurrent_version
操作選單項 disabled canWriteProfile(row)(整列唯讀)+ canCreate / canUpdate / canDelete can_editis_active+登入者 permissions(hasCap
操作選單「刪除」vs「停用」 actionMenuUsage(開選單前查、不 await) GET /<uid>/referencing-usagein_use(未使用給刪除、已使用或未知給停用)
「修正來源」是否 disabled frozenusageReason(usage, t, 'profile') 同上 in_usecanWriteProfilecanCreate
操作選單頂端說明 actionMenuReadonlyReasonusageReason(...) 優先於 rowReadonlyReason(row) in_useprojects[].name(→使用中凍結文案,帶專案名)/ can_edit(→公版唯讀)/ is_active(→已停用)
刪除確認框的「N 個版本、M 條控制項」 loadVersions(row.uid)(版本子表同一份快取)→ 長度與 control_count 加總 GET /<uid>/versions不是判定端點——它回的是「被誰用過」不是「有多少東西」)
來源欄鎖 icon scope === 'SYSTEM' && can_edit === false scopecan_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_statusextraction_error
url 型快照提示(三態) source_typeextracted_atsource_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.profileprofile:<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.pyinspec.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. 邊界情況與已知坑

  1. 🔴 current_version 可以是 null,列表必須渲染得出來:整支停用過的殘留主檔就是這個形狀(DEV 有一筆)。版本欄顯示 、操作選單裡的「檢視控制項」與「版更」兩個項目整項不放(給一個點了會 404 的選項比不給更糟)。⚠️ 注意這裡是「不放」不是 disabled——CM-1078 之後選單的通則是「不能做的留著 disabled 並說明原因」,但這兩項是「這一列語意上根本沒有這個動作」(沒有當前版就沒有控制項可看、沒有來源類型可沿用),與「動作存在只是你不能做」是兩回事,兩者不可對齊成同一套。同時它不進任務下拉——派工綁的是某一版,沒有版可綁就不該出現在選單裡。任何假設「一定有當前版」的程式碼都會在這筆資料上壞掉。
  2. 分類字典的維護 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 了)。不要製造第二個懸空孤兒——此坑的存在就是為了讓下一個人知道那四支端點不是死碼。

  3. detection-profile.update capability 從 FR-060 起才有消費者:FR-059 時期四能力點全數 seed,但版更 / fork / 複製全是 INSERT 對 create、停用對 deleteupdate 沒有任何端點在用(當時 spec 稱它「預留孤兒」)。FR-060 的 PUT /detection-tool-profiles/<uid> 消費了它,權限矩陣不再有一列是死的。對角色勾 update 現在有實際效果——沒勾的人看得到編輯鈕但按不動(disabled)。
  4. 讀端點僅 JWT 無 capability 守門:列表 / menu / 詳細 / 版本歷史 / 控制項 / 抽取狀態 / 分類字典讀取都只 @jwt_required()——menu 是任務抽屜全員要用的(刻意開放),其餘讀取端跟著同粒度。可見範圍靠查詢收斂+RLS,不靠 capability;與寫入端點的守門粒度不一致,改動時勿混為一談(同 檢測工具管理 §12 坑 2 模式)。
  5. 「未分類」篩選在前端做,分頁筆數會與 meta.total 不一致:選了未分類時 FE 用 sentinel 值在本地過濾(BE 不知道我們又濾掉了一些),列表下方會出現提示。可接受的原因:未分類是待補的例外狀態、筆數本來就少,而讓 BE 支援「篩 NULL」要在 filter schema 上發明一個 sentinel 值——同樣的複雜度搬到契約層,而且會永久留在 API 表面。若未來未分類變成常態量級再回頭讓 BE 支援。
  6. fork 與複製到其他工具共用檔案實體不複製 binaryfile_id / sha256 沿用來源版——profile 檔不可變(版更=新列新檔),同內容存兩次沒有意義。含意:來源公用版停用不影響 fork 列(upload_files 實體仍在);反向也一樣。
  7. fork 不走 SYSTEM guard、複製到其他工具走:fork 寫的是新租戶列(scope 硬寫 TENANT);複製沿用來源 scope——公用版複製到另一池結果仍是公用版,故需平台管理員。兩者語意差異就在這裡,改動守門時別對齊成同一套。
  8. 派工不檢查 is_current 只檢查所屬基準的 is_active:任務綁定存的是使用者當初選的那一版 uid,版更後舊版仍是合法歷史版本,掃描照使用者所選執行(要換版是使用者自己回任務改選,不是 BE 偷偷跳版);停用才是「不該再被使用」的管理決策。參照失效(查無 / 停用)→ 派工當下 400 明確報錯,不原樣下發(agent 會拿到無法解析的 profile:<uid> 變成 agent 端神祕失敗)、不默默拿掉(使用者以為在掃 A 基準實際參數沒了)。
  9. profile: 前綴契約單點:menu 的 value 已是 BE 組好的 profile:<uid>,FE 不自己拼前綴;BE 端前綴判斷只有 common/util/detection_profile_ref.py 一份,派工展開不可自己寫 startswith。手填值不可能以 profile: 開頭——這是三種取值來源(庫內 / 手填 / 舊靜態選項值)零歧義共存的基礎。
  10. 🔴 三種 uid 不可混用:① 主檔 uid——列表列的 uid,基準的識別,編輯 / 版更 / fork / 停用端點收它;② 版本 uid——current_version.uid 與版本子表列的 uidprofile:<uid> 契約指的就是它,控制項與抽取端點(/detection-tool-profile-versions/<uid>/…)收它;③ upload_files uid——_profile.uid 對 file 型存的是這個(agent 拿它直接打取檔端點,該端點認的就是 upload_files uid),url 型無檔案實體時 _profile.uid 才是版本 uid。傳錯會 404 而錯誤訊息只會說「找不到」,所以控制項頁的入口一律從列表的 current_version.uid 或版本子表列的 uid 帶進來,不要手拼
  11. 上傳驗證是第一道不是唯一一道:bomb 檢查靠 entry 自報的宣告 size(header 值理論上可偽造)——偽造後 agent 端真解壓時與宣告不符會失敗,且 agent 端另有一套同等的 slip / bomb 防護(BE 驗過不代表可信任傳輸後內容)。本驗證器是全 repo 第一個壓縮檔安全實作,之後任何要收壓縮檔的功能應來 common/util/detection_profile_archive.py 復用,不要各自再寫一套。
  12. 「掃描不代管 url」與「抽取代為下載」是兩件事,別對齊成同一套掃描路徑——agent 原樣把 URL 交給檢測引擎自行下載,平台不驗證可達性、不代管憑證(此界線在設計即明寫,含 FE hint 文案,別當缺陷回報)。抽取路徑——BE 代為下載到記憶體解析(SSRF 五道防護),但不落儲存、不改 source_typefile_id / sha256 維持 NULL。存成 file 型快照會讓 url 型悄悄變成 file 型、破壞雙軌語意,且掃描(agent 拉 URL)與抽取(平台存快照)從此可能對不上,對不上時沒人知道該信哪個。附帶含意:url 型抽出來的是某一刻的快照master branch 會漂)、沒有 sha256 當信任根,前端明示給使用者。
  13. 🔴 severity_normnone 是「不做自動判定」不是「風險低」:InSpec 的 impact 0.0 表示這條需人工判讀,不是低風險。前端因此不把 none 畫成 low 的顏色而走中性灰底的「不判定」——否則 199 條人工待判項會被誤讀成 199 個低風險項。同理嚴重度欄位刻意不叫 impact(那是 InSpec 專有的 0.0–1.0 浮點語意,XCCDF 是五級列舉,寫死會讓第二個格式進來時只剩改表一條路)。
  14. 抽取的 pending 是「還沒觸發過」不是失敗:空狀態文案不可寫成「抽取失敗」或「這份基準沒有內容」——DEV 現況多數版本就停在這個狀態(拆表搬遷進來的既有資料沒有跑過抽取)。寫成失敗會讓使用者去追一個不存在的問題。同理 control_countnull 與 0 是兩件事(null=還沒抽完;0=抽到了但真的沒有)。
  15. ⚠️ 本功能現況只在 DEV:三張新表 / 分類字典 / capability / 選單路由 / param_schema 宣告 / 搬遷 seed 全數只套 DEV(環境異動鐵律)。param_schema 相關 migration 尤其不可單獨誤套 STG / POC——FR-059 那支拿掉靜態 options,而該兩環境沒有搬遷 seed 資料,套下去任務抽屜下拉會直接空掉;必須整批依序等決策者放行。另外 STG / POC 主機尚未安裝 CINC Auditor(安裝等放行)——未裝的環境上傳與掃描都正常,只有抽取會落 failed。
  16. 同名檢查是業務層前置、唯一索引是兜底find_by_exact_name 查同租戶同工具的名稱;公用版與某租戶自有版可以同名(不同 tenant_id 池)——fork 預設沿用原名能成立正是因為這點。改名時「改成自己現在的名字」不算撞名,故 service 先比對再查。
  17. 「停用=讓出名字」的語意由主檔 partial 唯一索引維持uq_dp_tool_tenant_nameWHERE is_active,停用列不再佔名。這是 CM-1047 在舊表修好的行為(2026-08-03 當天),拆表時刻意延續——若日後把 partial 條件拿掉,「停用後同名再建撞 409」會原地復活。 > 拆表廢掉了舊模型那套配套補償:舊表拿 name 當身分,需要三組唯一索引+「新建版號從同名 is_active 列 max(version)+1 續號」才能維持這個語意;新模型名稱唯一性收在主檔、版號唯一性收在從檔 (profile_id, version),兩者解耦,續號規則整個消失。看到舊文件講「三組索引」「起始版號續號」時知道那是拆表前的事。
  18. 🔴 「版本層可刪、主檔層不可刪」是合法狀態不是矛盾:版本層判定穿透軟刪專案(那些引用不計入)、主檔層不穿透,兩者嚴格度刻意不同——因為兩種刪除的破壞半徑不同(刪一版只掉那一版的控制項;刪主檔連帶清掉所有版本與控制項,而任務可能已刪、報告與證據還在)。看到同一支基準「某一版可以刪、整支不能刪」時不要當成 bug 去追。同理主檔層會出現 in_use=trueprojects 為空的合法狀態(引用全在使用者看不到的軟刪專案裡),組訊息不可假設非空——畫面上出現空括號就是這裡漏判。
  19. 🔴 「用過即凍結」沒有後門,也不該加:修正來源 / 刪除三支方法的簽章不得出現 force / confirm / override(有測試專門釘住這件事)。理由是稽核可信度而非 UX 保守:版本 uid 是派工契約、sha256 是 agent 對帳基準,同一個 uid 在不同時間指向不同內容,日後回頭看掃描報告沒人能確定當時掃了什麼。日後若有人提「加個確認框讓管理員硬改」,那正是這條要擋的東西——出路是建立新版本(版號本來就是為此存在)。
  20. 使用判定漏一個引用來源=靜默放行刪除:判定必須同時看三個階段(任務已綁定 / 已派工單 / 已有執行歷史),且 ②③ 要加 source_type='url'(file 型的 _profile.uidupload_files uid,不加會永遠比不中)、jsonb 存在判斷要用 jsonb_exists() 不可用 LIKE '%_profile%'_ 是 LIKE 的單字元萬用字元,DEV 實測錯誤寫法回 32 筆、正確 7 筆)。守門一律委派 canonical 的 _version_usage() / _profile_usage(),不在守門處重查、也不繞過去直接用 infra 的 query class——只能有一份真相。
  21. 刪除是硬刪,ON DELETE SET NULL 不是業務規則current_version_id 的 FK 雖是 SET NULL,那是防資料損毀的最後一道;「當前版不可單獨刪」擋在業務層(409011),因為指標懸空後派工就拿不到可綁的版本。同理連帶刪除靠的是兩層 ON DELETE CASCADE,這代表刪主檔不可逆、沒有任何軟刪痕跡可回溯——與「停用」的差別必須在 UI 上講清楚(見 UC-DPM-07)。
  22. url 型過期偵測:判太鬆比判太嚴更糟:探測不到驗證器、探測失敗、來源不給 ETag——一律當「未變更」。判成有變更的代價是那類來源每次開頁重跑一次 CINC(234 條 28 秒、708 條 117 秒),而且症狀是靜默的(畫面一切正常,只有主機在燒)。另兩個容易踩的點:① Accept-Encoding 必須釘死 identity(ETag 隨編碼協商而變,httpx 預設送 gzip,探測與下載不一致就永遠比不相等);② 驗證器只在抽取成功時落庫、兩欄一起寫(失敗也記等於那一版永遠停在失敗態且無自動復原路徑)。
  23. 一般 succeeded(未過期)態目前沒有重抽入口:BE 端點 POST /detection-tool-profile-versions/<uid>/extraction 一直存在,但控制項頁只在 failed / pending / url 型偵測到過期三種情況顯示「重新解析」按鈕——沒過期時給按鈕等於鼓勵使用者對著沒變的來源重跑 CINC。這是刻意的現況,不是漏做;要重抽一份未過期的快照目前只能改來源或版更。
  24. 舊表 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 Auditorcurl -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-core gem(Chef EULA)。缺了只影響抽取,上傳與掃描照常。詳見 docs/claude/host-dependencies.md §③
  • 測試帳號:dev 環境持 detection-profile.create / update / delete capability 的帳號(比照流程範本管理授予的租戶系統管理員角色;公用版與分類字典維護需 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-*.sql seed;上傳測試可用 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,狀態走 pendingrunningsucceeded(約 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 → 409 DETECTION_TOOLS_409009
  • 刪除(硬刪):刪未使用的主檔 → 主檔 / 版本 / 控制項經 CASCADE 全數消失(DB 實查而非只看畫面),確認框的「N 個版本、M 條控制項」與實際筆數相符;刪已使用的主檔 → 409 DETECTION_TOOLS_409010;刪當前版 → 409 DETECTION_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 而非空白或報錯
  • E2Ecompliance-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.mddiscussion.html;下拉動態化與三層取值來源見 任務配置;執行紀錄的 profile 標籤對照見 我的任務 §11.1;工具目錄與 param_schema 兩層 schema 關係見 檢測工具管理;主機依賴見 docs/claude/host-dependencies.md;使用手冊 docs/user-manual/scan-profile-guide.md