FR-060 檢測基準內容管理 — 設計文件

功能編號:FR-060|名稱:檢測基準內容管理(Detection Profile Content Management) 狀態:設計定案,待實作(D1–D19 全數拍板)|建立日期:2026-08-03 前作:FR-059 掃描設定檔庫(Profile 庫)/FR-058 檢測工具整合平台|後續:FR-061 選用範圍・豁免・人工判定・自訂項 工作 branch:feature/e2e-env-build 討論稿(含四張流程圖與五支探脈實測):discussion.md

變更紀錄

日期 版本 變更
2026-08-03 v1.0 設計定案(D1–D19 全數拍板)
2026-08-03 v1.1 D5 改為 enum 存 DB(推翻原「FE 寫死」建議);多語不落庫,DB 只存 key、由前端 i18n 翻譯
2026-08-03 v1.2 釐清 RLS 掛載判準(控制項表不掛,補既有慣例佐證);記錄公版名稱/描述多語化為已識別未實作項

1. 需求背景與 WHY

1.1 使用者現在看到的只有一個名字

FR-059 解決了「profile 怎麼進系統」——把掃描設定檔打包收進平台庫、由 agent 拉檔執行。但平台把 profile 當成不透明的二進位檔,完全不理解它的內容:使用者在管理頁與任務下拉看到的只有一個名稱(例如「TWGCB-01-014 Ubuntu 22.04 LTS」),不知道它會驗什麼、驗幾項、哪些項目其實跑不出結果。

決策者原話(2026-08-03):

目前只有項目,user 完全不知道這個東西會驗證什麼,我怎麼知道我要選什麼?哪些基準可以參考,我可以先請負責人去判斷、提前先處理,而不是等報告出來才去處理。

1.2 一組實測數字說明急迫性

拿系統內最常被選用的 TWGCB-01-014(Ubuntu 22.04 政府組態基準)在 DEV agent 主機(192.168.50.123,CINC Auditor 7.1.7)實跑抽取:

指標 數值
控制項總數 234
人工待判項(impact 0.0,執行時直接 skip) 199
真正會自動檢查的條數 35
自動化覆蓋率 15%

使用者現在完全看不到這件事。他選了基準、跑完掃描、拿到一份報告,才發現絕大多數條目是 skip——而 skip 的意思是「這條要人工判斷,工具幫不了你」。這正是原話裡「先請負責人去判斷、提前先處理」所指的缺口:199 條人工待判項如果在選擇當下就看得到,負責人可以提前展開作業;等報告出來才知道,等於白等一輪掃描。

1.3 三個需求

# 需求 內容
瀏覽 profile 檢驗內容(核心) 打開一支基準,看得到控制項清單:編號、標題、說明、期望值、分類標籤、是否為人工待判項。使用者要能在選擇之前判斷「這份基準適不適合我」「我要先請誰去準備什麼」
修改基本資料(含改名) 租戶可以改自己上傳的 profile 名稱與描述;公版(scope = SYSTEM)只有 root 能改。現況是改不動的——名稱參與唯一索引,唯一的改名途徑是複製一份新的(fork 時順便改名)
分類體系 決策者指出兩件事:CINC 的 profile 不只作業系統,連瀏覽器都有(還有資料庫、容器等);以及 profile 一多,下拉會很長、會選錯。需要一套分類軸讓下拉分組、列表篩選

下一階段(FR-061)才做的是對基準內容動手的能力:選用範圍、豁免、人工判定、自訂項。本案只定義「資料模型必須支撐什麼」,不做實作——這些能力直接改變掃描參數,改錯會讓結果不正確,必須等瀏覽與分類上線、看到實際使用情形再定形狀。

1.4 為什麼一個「瀏覽功能」要順帶重構資料模型

決策者在討論過程中明確要求:「你要拿出你專業設計師的模式,而不是每次都用最快達到要求的模式把功能做出來,底層設計很重要。」

三個需求裡有兩個被現行資料模型直接卡住,第三個無處落腳:

  1. 名稱被當成身分,所以改名改不動——現行唯一索引是 (detection_tool_id, tenant_id, name)。改名會撞索引、會讓版本鏈斷開。
  2. 分類屬性歸屬錯層——分類是「這支基準是什麼」的屬性,應該跟著基準走,不該每個版本重填一次。現行單表沒有「基準」這一層,只有「某支基準的某一版」;分類存單表會每版重複,版更漏帶就漂移。
  3. 控制項無處可放——234~708 條控制項是「某一版的內容」,需要一張自己的表,且要能被 FR-061 的豁免以外鍵參照。

因此本案第一步是主從拆表

detection_profiles          基準主檔:名稱 / 描述 / 分類三軸 / 範圍 / 所屬工具 / 啟用狀態
  └─ detection_profile_versions   版本從檔:來源檔 / sha256 / 版號 / 當前版旗標 / 抽取狀態
       └─ detection_profile_controls  控制項:一條一列,跨格式正規欄 + attributes jsonb

而且現在是最便宜的時機:DEV 僅 13 筆資料(10 筆 SYSTEM/3 筆 TENANT)、FR-059 上線才兩天、沒有租戶真的在用。等 FR-061 把選用範圍與豁免綁上去再拆,成本是現在的好幾倍。


2. 端到端流程

2.1 上傳到瀏覽的主幹道

使用者上傳 profile 壓縮檔
  → BE 結構驗證(找 inspec.yml / 防壓縮炸彈 / 防 zip-slip)── 同步、毫秒級
  → INSERT 版本列(extraction_status = pending)
  → 立即回應前端「上傳成功,內容解析中」    ← 不等抽取
  → 排入背景抽取工作(extraction_status = running)
  → 壓縮檔 bytes 落成臨時檔(NamedTemporaryFile,副檔名必須正確)
  → 依 profile_format 取對應 extractor(本期只實作 InSpec)
  → cinc-auditor json <臨時檔>      ── 234 條約 28 秒 / 708 條約 117 秒
  → 解析:剝掉 source_location 絕對路徑前綴 / 丟 code / 丟重複 desc
          impact == 0.0 標記人工待判
  → 批次 INSERT 控制項,extraction_status = succeeded
  → 刪除臨時檔
  → 前端輪詢(建議 5 秒一次)看到狀態轉換,開啟版本詳細頁瀏覽

失敗有三種分支,其中第三種最危險:

分支 判定 處理
正常 exit == 0controls 非空 落庫,succeeded
明確失敗(六種實測形狀) exit != 0,stderr 有明確訊息(無 inspec.yml/空目錄/路徑不存在/YAML 壞/檔案無副檔名等) failed,記錄錯誤訊息
靜默失敗(第七種) exit == 0、stderr 全空、JSON 合法,但 controls == [] 一律視為 failed,訊息「未解析到任何控制項,請確認 profile 內容」(見 D11)

抽取耗時與體積為實跑數據,非推估:

Profile 控制項數 JSON 體積 抽取耗時 丟 code + 丟重複 desc 後
TWGCB-01-014(Ubuntu) 234 704 KB 28–29 秒 約 292 KB
TWGCB-01-011(Windows) 708 1.82 MB 117 秒 約 870 KB

欄位佔比實測:code 47.8%tags 16.2%/descriptions 13.1%/desc 11.8%,其餘各低於 10%。丟掉 code 與與 descriptions.default 重複的 desc 共省 59.6%

117 秒遠超任何 HTTP 逾時設定——抽取必須非同步,這一點沒有折衷空間,同步做法在 Windows 那支上必定逾時。

🔴 紅框一:人工待判項的判準是 impact == 0.0,不可用任何自訂 tag

四種寫法交叉比對的實測結果:

判準 命中條數 結果
impact == 0.0 199 ✅ 正確。InSpec 原生語意,任何 profile 都有
code 含 skip 199 ✅ 正確。與上者同一集合
tags.status == 'pending-mapping' 199 ✅ 正確,但屬 TWGCB 自訂 tag,外來 profile 沒有
tags.check_type == 'pending' 193 錯誤,漏 6 條

twgcb_01_014_0079 ~ twgcb_01_014_0084 這 6 條標的是 check_type: 'service',但實際上 impact0.0 且程式碼只有一個 skip——它們是貨真價實的人工待判項,只是 tag 標錯了。

正解是 impact == 0.0——這是 InSpec 原生語意(impact 0 代表這條不做自動判定),客戶自帶的外來 profile 也一定有這個欄位,實測 100% 準確。不要依賴任何自訂 tag 做這個判定。(依 D19,此判準寫在 InSpec extractor 內,不是寫死在表結構的假設裡。)

2.2 既有派工鏈為何不受影響

本案 .1–.3 全在管理面,agent 拿到的 payload 形狀與今天一模一樣

  • common/util/detection_profile_ref.pybuild_payload() 全部讀從檔欄位,拆表後原樣可用。
  • agent_file_access_service._profile_ref_resolver 比對的是 upload_files 的 uid,根本不碰 profile 表。
  • 兩條凍結契約的 soft-ref 不變:config.job_execution_detection_tools.tool_params JSONB 的 4 筆 profile:<uid> 字串、compliance.agent_tasks.params JSONB 的 6 筆 _profile 物件。因為 D4 定案「uid 由從檔逐列沿用」,這 10 筆既有引用零轉換、零風險

⚠️ 注意 _profile.uid 的語意依 source_type 而異:url 型是 profile 列的 uidfile 型是 upload_files 的 uid(agent 拿它去打 FR-058.7 取檔通道)。兩個 uid 來自不同的表,搬遷時必須分開對待。

🔴 紅框二:派工鏈「零影響」的假設不成立,有兩處破口

實際 grep app/detection_tools/service/detection_orchestration_service.py 後發現兩處會壞,其中一處是無聲的授權漏洞:

破口 ① — _load_profile() 檢查 profile.is_active 拆表後 is_active主檔。若 _load_profile() 拿到的是從檔 entity:從檔沒有該屬性 → AttributeError → 500;從檔有但預設 NoneNone is False 為假 → 守門靜默失效,已停用的 profile 照樣派得出去。後者不會有人發現,比 500 更糟。

破口 ② — _humanize_profile_param()getattr(profile, "name", None) name 拆表後在主檔。這裡有 default 值所以不會報錯,只會讓通知信裡的「掃描設定」靜默退化成 profile:<uuid>,而且目前沒有任何測試會抓到這個退化

解法:domain service 新增 get_version_with_profile(uid),回傳主檔+從檔合成的 entity,orchestration 的三處改指它。對外行為零變化,且把「派工需要哪些欄位」收斂到一個方法裡。

2.3 環境紀律

開發期間所有 migration 只套 DEV。 STG 與 POC 一律等決策者當次明確指示才可套。 本案的拆表 migration 會 ALTER TABLE ... RENAME 一張 STG 正在服役的表,比加欄位危險一個量級。

環境 Host Port DB name 現況
DEV 192.168.50.188 25432 guidant_ai_dev 13 筆(10 SYSTEM/3 TENANT),全部 version = 1
STG 192.168.50.188 25432 guidant_ai_stg 已服役config.detection_tool_profiles 有 10 筆,全部 SYSTEM、無 TENANT,RLS 四段齊全
POC 192.168.50.189 25432 guidant_ai_poc 本次未查(遵守環境紀律,唯讀查詢也不主動做);上版前需另行唯讀確認

連線帳號為 cmmgr(跑 migration)/cm_app(受 RLS),密碼請查 .envDB_SECRET,不得寫入任何版控檔案

🔴 紅框三:「STG 跑得順」不能當驗收依據

STG 與 DEV 同為 125 筆 migration、FR-059 全 5 支都在,但 STG 資料全部是 SYSTEM、沒有任何 TENANT 資料。這意味著搬遷 SQL 在 STG:

  • 測不到租戶 RLS 路徑
  • 測不到 id 32/33 的三欄 JOIN 陷阱(同 tenant、同 name、同 file_id 但屬不同工具)
  • 測不到停用列造成的孤兒主檔current_version_id IS NULL 的合法狀態)

驗收必須在 DEV 完成——那裡才有完整的資料形態。


3. 決策定案(D1–D19)

以下每一項都含決策內容/定案理由/被排除方案與原因。被排除方案不可省略——未來要反悔時,必須看得到當初排除了什麼、為什麼。

3.0 已拍板兩項

✅ 抽取落點 = BE 主機安裝 CINC Auditor

決策內容:抽取工作在 BE 主機執行,不派給 agent。安裝方式:

curl -fsSL https://omnitruck.cinc.sh/install.sh | bash -s -- -P cinc-auditor -v 7

定案理由:抽取是上傳當下就要發生的事,不能依賴 agent 在線。成本已列明並接受:體積 275MB(其中約 200MB 是本案用不到的 AWS/Azure/GCP SDK 與遠端 transport)、三台部署機加每台開發機都要裝、收尾要在 docs/claude/host-dependencies.md 新增第三項(現有兩項是中文字型與 LibreOffice)。

  • ⚖️ 授權紅線:必須是 CINC Auditor(Apache 2.0)絕不可換成官方 InSpec 6+ 商業 binary(需接受 Chef EULA 並取得 license key);inspec-core gem 由 Chef 官方發布、同樣受 EULA 影響,也不可用。
  • 路徑解析比照 LibreOffice 慣例docs/claude/host-dependencies.md:85-89):環境變數 CINC_AUDITOR_CMD → PATH → 絕對路徑 fallback。不要硬編 /usr/bin/cinc-auditor(macOS 開發機路徑不同)。

被排除:派 agent 抽取——BE 端零主機依賴是優點,但要新增一種 agent 指令型別,且依賴 agent 在線才能抽取。使用者上傳完卻因為 agent 離線而看不到內容,體驗不可接受。

✅ 版本鎖定 = 鎖版 + 提示

決策內容:任務綁定版本 uid(即現行行為),FR-059 D2 契約零變動、10 筆既有綁定零轉換。但要配一個提示徽章:「此基準已有新版 v3,目前使用 v1」加一鍵換版。

定案理由:純鎖版有合規漏洞——TWGCB 發了新版,排程任務會無聲地繼續掃舊版,使用者以為自己合規、實際上不是。在 GRC 系統裡這是正確性問題,不是 UX 瑕疵。換不換是使用者的決定,但必須讓他知情。拆表後「是不是現行版」就是主檔 current_version_id 的一次比對,menu API 順手帶回,成本極低。BE 側留在 FR-060;FE 的徽章與一鍵換版若範圍太肥可移到 FR-061(BE 先把資料備好)。

被排除:跟隨現行版——同一張任務前後兩次執行會跑出不同結果、稽核無法回溯、破壞 FR-059 已凍結的契約,還要轉換 10 筆既有資料。代價全付了,換來的性質卻更差。


D1 · 主從欄位歸屬

決策內容

  • 主檔 detection_profilesnamedescription/分類三軸(標的類型/標的產品/基準體系)/scopetenant_idorg_unit_iddetection_tool_idis_activecurrent_version_id
  • 從檔 detection_profile_versionssource_typefile_idurlsha256versionis_currentsupportsextraction_status/控制項統計索引(總數/待判數)

定案理由:判準一句話——「換一版會不會變」。會變的進從檔,不會變的進主檔。supports(執行平台)放從檔,是因為它從該版的 inspec.yml 抽出來,不同版可能不同。detection_tool_id 放主檔,因為「這支基準給哪個工具用」是基準本身的屬性,行為與現行零變化。

被排除:分類放從檔——那等於每次上傳新版都要重填一次分類,且同一支基準的不同版可能分到不同類,篩選結果會前後矛盾。

D2 · 當前版指標:is_current 布林 + partial unique index

決策內容:保留 FR-059 現行的 is_current 布林,配 UNIQUE (profile_id) WHERE is_current 的 partial unique index。current_version_id 仍可存在當作查詢便利欄(避免列表每次都 join 從檔篩 is_current),但唯一性的保證來源是 partial unique index,不是這個外鍵

定案理由:資料庫直接擋住兩筆 current,demote_current 先降後插的順序是被索引強制的,不是靠開發者自律。

被排除:以 current_version_id 外鍵作為唯一性保證——這正是 oscal.frameworks 走過的路,而且官方已認錯並事實廢棄(2026-06-16 改用 main_version 純字串欄,外鍵降級為冗餘、列入待 DROP,見 docs/features/FR-038-2606-oscal-redesign/handoff/2026-06-16-framework-maintenance-fixes-record.md:60)。三個具體傷害:① 循環外鍵逼得 DDL 拆兩段、Python model 退化成 plain int 欄位;② 刪除流程被綁死,不能先刪版本只能靠 cascade;③ 資料庫保證不了「只有一個當前版」,而應用層從頭到尾沒實作過切換(add_version 完全不碰它)。

D3 · 從檔 RLS:冗餘欄位而非 EXISTS 子查詢

決策內容:從檔冗餘存 tenant_idscopeorg_unit_id,policy 與主檔逐字對稱(四段 × 兩表 = 八段)。

定案理由

  1. PostgreSQL 的 RLS 不沿外鍵繼承。 只掛主檔的話,SELECT * FROM 從檔 完全不受限制 → 租戶 A 能列出租戶 B 的全部 sha256file_idurlfile_id 洩漏搭配 FR-058.7 取檔通道,就是一條實質的檔案越權路徑。
  2. 從檔會被直接查——派工主幹道 _load_profile() 走的就是從檔的 get_by_uid,不是先查主檔再 join。
  3. 本專案零 EXISTS 繼承前例——detection_executionsjob_execution_detection_tools 等子表全部是冗餘欄位形狀,跟進既有形狀降低理解成本。

冗餘的代價:改主檔的 scopetenant_id 時要同步更新從檔。但實務上這兩者建立後幾乎不變(fork 是建新列而非改欄位),代價很低;集中在 app service 層處理,不要散在各處。

被排除:EXISTS 子查詢繼承主檔判定——① 效能,每一列都要跑一次子查詢;② 子查詢本身還會再套一次主檔的 policy,形成 policy 套 policy,除錯時極難推理。

⚠️ 兩項實作陷阱:app_tenant_allowed_for_session(integer) 只接 integer,而 tenant_id 是 bigint → 必須寫 tenant_id::integer四段 policy × 兩張表 = 共 8 處,漏任何一處都會在套 migration 當下硬失敗。另外本專案目前沒有任何「主從兩表都帶 RLS」的前例,FR-060 是首例,migration 內必須附上跨租戶讀取的驗證查詢。

D4 · uid 歸屬:從檔逐列沿用舊 uid,主檔另生新 uid

決策內容:舊表那 13 個 uid 逐列沿用給從檔;主檔另生新 uid。

定案理由:舊 uid 是凍結契約——job_execution_detection_tools.tool_params 的 4 筆 profile:<uid>agent_tasks.params 的 6 筆 _profile 都指著它。從檔沿用等於這 10 筆既有引用零轉換、零風險;語意也對——派工綁的本來就是「某一版」不是「某支基準」。

被排除:主檔沿用舊 uid——那 10 筆引用全部要改寫(改 JSONB 內容),而且語意變成「派工綁基準」,與已拍板的「鎖版」相牴觸。

D5 · 分類三軸的定義與 enum 來源

決策內容

型態 值域
① 標的類型 受控 enum(值存 DB) 作業系統/瀏覽器/應用程式/資料庫/網通設備/雲端服務/容器/其他(DB 存的是 key,此處為前端翻譯後的顯示樣貌
② 標的產品 自由文字 Ubuntu 22.04、Google Chrome、PostgreSQL 15 …(前端 autocomplete 既有值)
③ 基準體系 受控 enum(值存 DB) TWGCB/CIS/STIG/dev-sec/自訂(同上,DB 存 key)
(④ 執行平台) 不是第四軸 inspec.ymlsupports 自動抽取、唯讀不可編——它是事實不是分類

enum 值來源定案:存 DB,由 root 維護(🔴 決策者 2026-08-03 推翻原「FE 寫死」建議)。

決策者原話:

D5 存 DB 不要寫死,這類最好都要可以維護。

定案理由:新增一個標的類型不該需要發版。原建議把八個類型視為「穩定的分類骨幹」,但這個前提在 D19 之後不成立——多工具擴充上線後,新格式帶進來的標的類型只會更頻繁(XCCDF 系的框架涵蓋面與 InSpec 系不同),每次都要發一版是不合理的維運模式。標的產品維持自由文字而非 enum,是因為產品名稱長尾極長(客戶自帶的 profile 可能是任何東西),硬做 enum 會逼使用者選「其他」,反而失去篩選價值。

被排除:FE 寫死常數——這是原討論稿的建議。排除理由是「要新增類型就得發版」,與 D19「擴充點一律是加資料、不動已上線的東西」的精神不一致:D19 才剛把工具與格式的擴充從「改表」降級成「加一列宣告」,分類 enum 卻要改常數再發版,兩者標準不一。 被排除:標的產品做成 enum——理由同上,長尾會讓 enum 失去篩選價值。

enum 存 DB 帶來的三個附帶需求(因本次改決策而新增)

原討論稿以「FE 寫死」為前提,故未涵蓋下列三項。改存 DB 後三項都是必做,不是可選:

# 附帶需求 內容
顯示名稱不落庫,由前端 i18n 翻譯 🔴 決策者裁示:「i18n 不要用你的方式做,DB 也是存 key 就好,由前端翻譯,這樣才不用多開一張表。」DB 只存 key(純英數 slug,例如 os / browser / database / twgcb / cis / stig),不存任何顯示名稱、不開 *_trans 翻譯表、不走 failover 讀取。顯示層翻譯完全由前端既有 i18n 檔負責。理由:為了幾個分類值多開一張翻譯表與一套 failover 讀取鏈,維護成本遠高於收益
刪除保護 已被 profile 引用的分類值不可刪——刪掉之後既有 profile 的分類值會變成懸空孤兒(指向一個不存在的 key),列表篩選與下拉分組都會出現無法解釋的空白。至少要在刪除前檢查引用數並擋下;更好的做法是改為停用(加 is_active),停用後不出現在新建與編輯的選項裡,但既有引用仍解得出這個 key(顯示層照常走前端 fallback)
root 維護介面 需要一個 enum 值 CRUD 的落點(列表/新增/改排序/停用),權限限 root。⚠️ 不提供「改 key」——key 是既有 profile 引用的值,改掉等同刪掉再新增,會製造與刪除同款的懸空孤兒;要換名稱是前端 i18n 的事,不是 DB 的事。若 .2 的範圍評估後太肥,可先只做「讀 DB + 靠 migration seed 維護」,但必須在 FR-060 的 spec 明寫「分類值目前仍靠 migration 維護,尚未有管理 UI」——不要讓它變成第二個懸空孤兒。D18 已有前例教訓:FR-059 預留了 field_scope / hint_scope_system 兩個 i18n key 卻沒做 UI,一年後沒人知道那兩個 key 為什麼在那裡

前端三層 fallback 就是本案的多語方案

分類值改由 DB 供給 key 後,前端顯示邏輯直接照抄 DeviceManage.vue:36-57 的三層 fallback(那 28 行面對的是同一個問題:自由文字欄位要枚舉化、同時相容既有資料):

  1. i18n key 存在(用 te() 測試)→ 顯示翻譯
  2. 否則顯示 DB 回傳的 key 本身
  3. 再否則顯示原值;舊的自由文字用 unshift 保留在選項最前

這套 fallback 就是本案的多語機制,不需要另建任何東西——BE 只負責供給 key 與排序,翻譯是純前端的事。

⚠️ 這個做法的代價(要寫進 spec,不要讓未來的人以為是 bug)

root 在 DB 新增一個 enum key 之後,前端若還沒有對應的 i18n 條目,該值會以原始 key 顯示——畫面上會出現 network_device 而不是「網通設備」,要等前端補上翻譯並發版才會正常顯示。

這是可接受的優雅降級:不會壞、不會空白、不會報錯,篩選與分組功能全部照常運作,只是標籤不好看。但它必須寫進 spec,否則未來有人看到畫面上出現英數 slug 會當成 bug 去追。

換句話說,「新增分類值免發版」這件事只在功能層面成立;要讓新值顯示為中文,仍需一次前端發版。這個取捨是決策者裁示後的既定結果,記在此處供未來反悔時參考。

🔴 不要跟進 options.json

專案內另有一份 options.json 集中式 enum i18n 檔,但實查 grep "lang.options\." src/ 零命中——那份檔案是死的,沒有任何地方在用。看到它不要以為那是專案的 enum i18n 標準機制而跟進,正解是上面那套三層 fallback。

D6 · platform_hint 退役

決策內容:退役 platform_hint 欄位。拆表時不搬到新表,值隨舊表一起保留在 rename 後的 _deprecated 表裡。不要在新表建一個空的 platform_hint 欄位。

定案理由:它的功能被 D5 三軸完全覆蓋而且更精確——「這支基準是給什麼用的」由標的類型加標的產品表達,「它能在哪些平台跑」由 supports 自動抽取表達。而且實查 DEV 12 筆資料,值有兩種形態:空字串 '' 與大寫的 Windows髒值已經存在。一個沒有約束、已經有髒值、語意含糊的自由文字欄位撐不起分類。保留在 _deprecated 表裡是為了觀察期內若發現有人在用還撈得回來。

被排除:保留並清洗——清洗 12 筆很容易,但清洗完它仍然與新三軸功能重疊,只是多一個要同步維護的欄位。 被排除:在新表建空欄位先佔位——那會變成第二個沒人維護的髒欄位。

D7 · 改名的語意:整鏈一起改,且不升版

決策內容:改名是主檔一列 UPDATE,所有版本天然共用同一個名稱;不升版。

定案理由:名稱是「這支基準叫什麼」,不是「這一版的內容」。若歷史版本保留舊名,列表上會出現兩個看起來不同的基準其實是同一支,比不改更混亂。這個行為是拆表的自然結果,不需要額外邏輯。

被排除:改名建新版——版號應該反映內容變更。改個錯字就升一版,會讓版號完全失去意義,也讓「有新版了」的提示變成噪音。

D8 · 新增編輯端點 PUT /detection-tool-profiles/<uid>

決策內容:承 D7,既然改名/改描述/改分類都不升版,就需要一個純粹的「編輯基本資料」端點。新增 PUT /detection-tool-profiles/<uid>,消費那顆已 seed 但至今沒人用的 detection-profile.update capability(FR-059 spec §12 坑 2 稱它為「預留孤兒」)。

權限規則:租戶只能編自己的(scope = TENANTtenant_id 相符);公版(scope = SYSTEM)只有 root 能編。此判定走 common/authz/資源域守門(app service 層),不要另立 helper

定案理由:現況完全沒有「編輯基本資料」的入口——名稱只能在 fork/copy 時順便改,等於「改名要先複製一份」,這正是需求 ② 的直接來源。附帶效益:那顆 capability 從孤兒變成有實際消費者,權限矩陣不再有一列是死的。

被排除:沿用建立端點做 upsert——建立與編輯的權限判準不同(建立看 scope 能不能選、編輯看資源歸屬),混在一起會讓授權判定難以推理。

D9 · fork/copy_to_tool 的版本複製語意

決策內容只複製當前版,成為新基準的 v1(與現行為一致);控制項一併複製(不重新抽取),extraction_status 一併設為 succeeded。fork 與 copy_to_tool 抽成共用的 _clone_profile(),差異收斂成三個參數(scopetenant_idtool_id)。

定案理由:fork 的意圖是「我要以這份為起點做我自己的」,歷史版本對新的那支沒有意義(那是原基準的演進史,不是新基準的),且複製全部版本會讓儲存量與 file_id 引用倍增。控制項內容完全相同,重抽要再等 28~117 秒沒有意義。

被排除:複製全部版本鏈——儲存量與引用倍增,換來的是對新基準無意義的歷史。 被排除:fork 後重新抽取控制項——內容完全相同,白等一輪抽取。

D10 · 抽取非同步化與 extraction_status 落點

決策內容extraction_status 四態 pendingrunningsucceededfailed放從檔;再配一個 extraction_error 文字欄存失敗訊息。前端在列表與版本子表顯示狀態徽章,pendingrunning 時輪詢(建議 5 秒一次)。

定案理由:實測 708 條的 profile 抽取要 117 秒,遠超任何 HTTP 逾時,非同步是必然不是選項。狀態放從檔,是因為抽取是對某一版做的,不是對整支基準。

被排除:extraction_status 放主檔——主檔若只有一個狀態欄,上傳 v3 失敗會讓 v1、v2 的狀態一起被覆蓋成 failed,但那兩版的控制項其實好好的。 被排除:走 WebSocket 推播——為一個低頻操作接推播不划算,且專案的 SocketIO 走另一個 port。

D11 · 抽取失敗的判定條件

決策內容exit != 0controls == [],兩者任一都算失敗。

定案理由:實測第七種錯誤形狀——.rb 控制項檔有 Ruby 語法錯誤時,cinc-auditor jsonexit 0、stderr 全空、輸出合法 JSON、但 controls 是空陣列,整個檔案的控制項(包含語法正常的那些)全部被靜默丟棄。若只看 exit code,使用者會拿到一支「抽取成功但零控制項」的基準,他會以為是這份基準本來就沒東西,而不是解析壞了。靜默的錯誤比明顯的錯誤更貴。

誤判風險評估:理論上存在「合法但零控制項」的 profile(純 wrapper profile 只有 depends 沒有自己的控制項),但那種 profile 在本系統的使用情境下也是無效的(掃了什麼都不會驗)。把它判成失敗是正確的,錯誤訊息寫「未解析到任何控制項,請確認 profile 內容」即可。

被排除:只看 exit code——會讓第七種錯誤形狀變成無人察覺的資料缺漏。

D12 · 控制項的存儲形式:一條一列拆表

決策內容:控制項一條一列存入 detection_profile_controls欄位以「跨格式共通的最小集合」為正規欄,格式專屬的中繼資料整包進 attributes jsonb(見 D19),丟掉 code、丟掉與 descriptions.default 重複的 desc

完整欄位:

類別 欄位
正規欄 control_idtitledescriptionseverity_rawseverity_normis_pendingsource_ref(剝掉絕對路徑前綴的相對路徑)
標記欄 extraction_format(哪個格式抽出來的)+ originextractedcustom,FR-061 自訂項用;兩者正交,不可合併)
其餘 全進 attributes jsonb(InSpec 放 tagsdescriptions;XCCDF 未來放 identfixtextrationale),為常用 key 建 GIN 索引(至少 category

🔴 欄位命名不可用 InSpec 專有名詞(D19 定調):嚴重度欄位不叫 impact——那是 InSpec 的 0.0–1.0 浮點語意,XCCDF 用五級列舉,其他工具各有各的。改成 severity_raw(原樣存來源值,文字)+ severity_norm(跨工具可比的統一級距)。is_pending 保留欄位名,但判準寫在各格式的 extractor 裡(InSpec 是 impact == 0.0,XCCDF 另有 role=unchecked),不是寫死在表結構的假設裡。

定案理由(決定性)FR-061 的豁免要能綁單一控制項——那需要控制項是一列可被外鍵參照的資料,不是 jsonb 陣列裡的一個元素。這是不可逆的架構決定,現在做對成本很低,之後改要動 FR-061 已寫好的東西。其他理由:搜尋/篩選/分頁走 SQL 而非 Python;「人工待判 199 條」這種統計是一句 COUNT(*) WHERE is_pending;未來要跨 profile 找「哪些基準有驗這條」也有路。

attributes 整包存 jsonb 的實測理由:tag key 集合隨 profile 變動——TWGCB-01-014 的 twgcb_idrolecategorygpo_pathgcb_valuecheck_type 各 234/234、status 199/234;TWGCB-01-011 則多出一個 service_display_name(368/708)。客戶自帶的外來 profile 更不可能有 TWGCB 那套 key,schema 本來就不可寫死成固定欄位

期望值兩條路都拿得到(tags.gcb_valuedescriptions.gcb_value,本 profile 兩者一致),以 tags 為主、descriptions 為 fallback

code 的代價:使用者看不到「這條實際怎麼檢查」的 Ruby 原始碼。可接受——目標受眾是合規負責人不是 InSpec 開發者,47.8% 的體積換一個幾乎沒人看的欄位不划算;若日後有需求,從 source_ref 可以定位到檔案,不是完全無路。

被排除:整支 profile 的控制項存成一個大 jsonb——豁免無法以外鍵綁單條控制項,FR-061 直接卡死;搜尋、篩選、統計全部退回 Python 端處理。

⚠️ 兩個容易混淆的欄位:source_location.ref執行當下的絕對路徑(含臨時目錄名),落庫前要剝掉前綴只留 controls/xxx.rb,否則存進去的是無意義且會洩漏內部路徑的字串;頂層 sha256 是 CINC 自算的 profile 內容雜湊,detection_tool_profiles.sha256(壓縮檔原始 bytes 的雜湊,FR-039 對帳的信任根)不是同一個值,切勿混用。

D13 · menu API 保持扁平,由 FE 自己 groupBy

決策內容:BE 的 menu API 維持扁平陣列 [{value, name, target_type, target_product, benchmark_family, ...}],分組由 FE 自己做。

定案理由:① 相容性——JobExecutionDrawer.vue:279-282for (const m of items) 若拿到巢狀會靜默失效(不 throw、不進 catch),任務抽屜的參數顯示直接退回裸的 profile:<uuid>,使用者看到一串 UUID;② 彈性——FE 想改用哪一軸分組(依標的類型或依基準體系)不必動 BE;③ 符合 CLAUDE.md 的 Menu pattern(回傳 List[MenuDto] 不包 PageDataDto)。

PrimeVue 3.53 的分組能力已實查確認:Dropdown 支援 optionGroupLabeloptionGroupChildrenDropdown.d.ts:289,293Dropdown.vue:285-293,881-891),editable 與分組可以並存editable 只切換輸入框呈現、visibleOptions 的計算完全不看 editableisValidOption 已排除 group 分隔項)——這對本案關鍵,FR-059 P6 定案「select_or_text 手填能力保留」,分組不能犧牲手填。群組標頭天然不可點選;filter 與分組的協同路徑現成(Dropdown.vue:910-920);專案內已有實例 RoundApAuthoringView.vue:779

被排除:BE 回傳巢狀——省了 FE 十幾行 groupBy,換來一個靜默失效的相容性風險,不划算。

必配的 fallback 修補(不論選哪個形狀都要做)DetectionConfigField.vue:82-92 有一段 opts.some(o => o.value === current),用來判斷「目前選中的值是否還在選項裡」,不在就補一個「已不可用」的 fallback 項。選項改成巢狀後這個比對必然全部 miss(它比對到的是 group 物件不是 leaf 選項)→ 每一個已選值都會多長一個「已不可用」項。不會報錯、不會進 catch,只會讓每張已設定的任務都顯示成「這個 profile 已不可用」。對策:比對前先攤平選項樹;fallback 項本身也要包成一個群組,否則會混在群組之間造成渲染錯亂。

D14 · 列表語意:主列表一列 = 一支基準

決策內容:主列表一列代表一支基準(主檔),預設只顯示 is_active 的;版本歷史走展開列(expander,pattern 抄 SheetPreviewControls.vue),並另有獨立分頁 API 供版本多的情況。原有的「顯示已停用與舊版」開關拆成兩個:主列表上是「顯示已停用基準」(主檔 is_active = false),版本子表上是「顯示歷史版本」(從檔 is_current = false;子表本來就該全顯,這個開關可能根本不需要)。

定案理由:拆表後「已停用的基準」與「非當前版」是兩個獨立概念,混在一個開關裡語意分裂。

被排除:一列一版本(維持現行形狀)——那等於沒拆表,改名、分類、篩選全部回到原點。

FE 實作素材:DetectionProfileManageView.vue 共 1,239 行,其中約 600–700 行要重構,建議趁機拆檔(主表格/版本子表/各 Dialog 各自獨立元件),否則會膨脹到 2,000 行以上。控制項清單(234~708 條)以 client-side DataTable + :paginator 25 筆/頁即可(PrimeVue 只渲染當前頁);搜尋複製 DetectionProfileManageView.vue:506-513 的 IconField(已含 300ms debounce)。不建議 server-side lazy loading——控制項是某一版的靜態內容,載一次快取住比每次翻頁多打 N 次 API 快得多;VirtualScroller 專案內零使用,除非實測超過 1MB 或 2 秒,否則不要為這個功能開一個新 pattern。分類枚舉化直接照抄 DeviceManage.vue:36-57 的三層 fallback(i18n key 存在用 te() 測 → DB 回傳的 key → 原值,舊的自由文字用 unshift 保留在最前);⚠️ 不要跟進 options.json,實查 grep "lang.options\." src/ 零命中,那份檔案是死的。

D15 · is_active 只放主檔,從檔第一期不設停用欄

決策內容is_active 只放主檔(=停用整支基準),從檔不另設停用欄。

定案理由:現況 13 筆裡沒有任何「想停用某一版但保留其他版」的需求證據(唯一的停用列 id 33 是整支不要了)。加一個 boolean 欄很便宜,但憑空設計一套「版本級停用」的語意(停用當前版後哪一版變 current?)很貴。先不做,等出現實際需求再加。

id 33 的搬遷處理:它會變成「一支 is_active = false 的主檔,底下有一個版本,但 current_version_id 是 NULL」——這是合法狀態,UI 要能顯示(不要因為 current 是 NULL 就渲染爆掉),migration 的驗證查詢也要預期 current_version_id IS NOT NULL 只有 12 筆而非 13。

未來若要加:從檔加 is_deprecated(而非 is_active,避免與主檔同名混淆),且要定義「棄用當前版時 current 怎麼移轉」。

被排除:第一期就做版本級停用——沒有需求證據,且語意設計成本遠高於欄位成本。

D16 · 舊表 rename 保留,觀察期後另開 migration 才 DROP

決策內容:本案的 migration 只做 ALTER TABLE ... RENAME TO detection_tool_profiles_deprecated_20260803不 DROP。DROP 另開一支 migration,於觀察期(建議一個 release 週期)後執行。

定案理由:專案慣例——「重構搬遷」與「刪舊表」是兩支獨立的 migration,中間隔一段觀察期。前例:2026-06-03-log-tables-partitioning.sql:662026-05-19-ssp-system-implementation-restructure.sql:255-280(附反向 ROLLBACK 註解)、2026-05-24-ssp-oscal-alignment-drop-old.sql(獨立成一支才真正 DROP)。

⚠️ rename 不會自動改索引名稱 → 新表的索引一律用新前綴(idx_dp_*idx_dpv_*idx_dpc_*),避免與舊表殘留的索引名撞名導致 migration 中途失敗。DROP 前比照 CLAUDE.md「DROP 退役表前安全四查」,實際 grep 確認沒有任何 live-wired 的 code 還在引用,不要假設。

被排除:本案直接 DROP 舊表——一旦搬遷有誤,資料無法回頭;且違反專案慣例。

D17 · 既有 13 筆的分類補登:有把握的才填,其餘留 NULL

決策內容:migration 內做部分補登——名稱裡含 twgcb-01-* 明確是作業系統、twgcb-02-003-google-chrome 明確是瀏覽器,這類從名稱可高信心推導的直接填;推不出來的留 NULL,由使用者在管理頁補。下拉分組時「未分類」放最後(分類明確是常態、未分類是待補的例外),列表篩選要有「未分類」選項讓使用者找得到待補的。

定案理由:13 筆裡有 10 筆是我們自己 seed 的 TWGCB 公版,我們比使用者更清楚它們是什麼。

被排除:全部留 NULL 等人工補——把已知資訊丟掉,等於讓使用者做我們已經知道答案的工。 被排除:全部靠猜測規則自動填——猜錯比留白更糟(使用者會信任錯誤的分類去篩選,篩掉他該看的東西)。寧可空著也不要猜。

D18 · 補做 root 建公版的 UI,讓兩個懸空 i18n key 活起來

決策內容:補做 root 建立公版的 UI,消費 FR-059 預留但未使用的 field_scopehint_scope_system 兩個 i18n key(現況 createDialog.scope 恆為 TENANT,root 沒有辦法從 UI 建公版,只能靠 migration seed)。scope 欄位對非 root 隱藏或唯讀。

定案理由:本案要開放編輯基本資料(D8),編輯 Dialog 與新增 Dialog 是同一組欄位;既然都要重構那 600–700 行,順手補上成本極低。而且公版只能靠 migration 建本身就是 FR-059 未收乾淨的尾巴——每加一支公版又要寫一支 migration,正是 FR-059 立案時要消滅的維運模式。

被排除:標記刪除這兩個 key——那等於承認「公版只能靠 migration 建」是最終狀態,與 FR-059 的立案動機相牴觸。

若實作時發現範圍太肥:至少要在 FR-060 的 spec 明寫「公版建立目前仍走 migration」,不要讓這兩個 key 繼續懸空無人知曉。

D19 · 多工具擴充邊界(決策者當場定調,非「照建議」)

決策者原話(2026-08-03):

要預留擴充方式,以目前有的工具為主,未來如果又有多工具有需要設定檔的,也要可以擴充,不能像這次一樣又大幅度更新,上線後很危險。

問題背景:工具維度本來就在——detection_tool_id 是建表就有的 not-null 欄位,唯一索引三欄含它,列表頁已有工具篩選、建立時必選工具、「複製到另一工具」整個功能繞著它轉。但目前只有 InSpec 系真的入庫

工具 profile 欄位 選項來源 DEV 筆數
gcb 政府組態基準 select_or_text options_source: profile_library ← 接庫 10
inspec CINC Auditor select_or_text options_source: profile_library ← 接庫 2
openscap OpenSCAP select_or_text 硬編 3 個 XCCDF profile id,沒接庫 1
其餘五個(openvas/nessus/sonarqube/zap/nmap) 無 profile 欄位 0

掛在 openscap 底下那 1 筆是 id 33,即「使用者選錯工具後停用重建」的殘留,是誤操作痕跡不是 OpenSCAP 真的在用庫。

🔴 若照原提案直接刻 InSpec 語意,第二個工具進來就要改已上線的表。 原 D12 提案的欄位(impact 浮點/tagsdescriptionsis_pendingimpact==0.0 推導)是 InSpec 專有語意。XCCDF 的對應概念完全不同:severity 是五級列舉不是浮點、沒有「人工待判」概念(另有 role=unchecked)、中繼資料是 ident(CCE/CCI)/fixtextrationale。第二個工具入庫時只剩兩條路:加一個 severity 欄,或把五級硬塞進浮點——兩條都是上線後改表,正是決策者指的「大幅度更新、上線後很危險」。

定案:三層擴充結構,擴充點一律是「加資料/加實作類別」,不是「加欄位/改欄位語意」

① 工具端宣告能力——沿用既有慣例,不發明新機制。 專案已有這個 pattern(detection_tool_param_schemasoptions_source: profile_library 就是「這個工具的設定檔走庫」的宣告)。把宣告補完整:加 profile_formatinspecxccdf/…)+ supports_extraction(能不能抽內容)。新工具入庫 = 加一列宣告,不動任何表。

② 抽取器走註冊表——依 profile_format 取對應 extractor。 管線不寫成「呼叫 cinc-auditor」,寫成「依格式取 extractor」。新格式 = 新增一個 extractor 類別 + 註冊一行,不動管線骨架、不動既有工具路徑。附帶效益:BE 裝 CINC 這項主機依賴只綁 inspec 格式,不會變成全平台前提。

③ controls 表存正規化語意 + 原始中繼資料分離。 正規欄只收所有格式一定都有的最小集合(control_idtitledescriptionseverity_rawseverity_normis_pendingsource_ref);格式差異全進 attributes jsonb;extraction_format 標記來源格式讓讀取端知道該期待什麼;originextractedcustom)與它正交,供 FR-061 自訂項使用。

代價要講清楚:現在只有 InSpec 一種真實格式,正規化是「照一個實例抽象」,有抽錯風險。降低風險的做法是正規欄只做最小集合——不要為了預想中的 XCCDF 去猜欄位。寧可多留在 attributes 裡,日後有第二個實例驗證後再提升為正規欄(那是加欄位,不是改語意,成本可接受)。

被排除:明寫「profile 庫只服務 InSpec 系」——太樂觀。決策者已明確表示未來其他工具的設定檔也會在這裡維護,把邊界劃死等於把問題推給下一次改表。 被排除:現在就完整抽象化(正規欄涵蓋預想中的 XCCDF 欄位)——只有一種真實格式時抽象化容易抽錯,沒有第二個實例可以驗證抽象是否正確。抽錯的抽象比沒有抽象更貴,因為它會誤導後續實作照錯的形狀寫。

對其他決策項的影響:D12 欄位定義已依此改寫(不用 impact 當欄位名);.1 建表採上述形狀;.3 抽取管線改註冊表骨架(工作量差異不大,都是新寫);D5 的「基準體系」軸不受影響——CIS/STIG 本來就是跨格式的體系名。


D20 · url 型基準的抽取:BE 代為下載,但只在記憶體、掃描路徑一行不改

背景.3 抽取管線(CM-1065)上線後,url 型基準(DEV 有兩支 dev-sec 公版)一律落 failed,訊息是「平台不代為下載」。使用者在下拉裡選得到它們,卻完全看不到內容——這正是本案 §1 原始需求要解的同一件事(「使用者完全不知道這個東西會驗證什麼」)。

推翻原假設的實測:協調者 2026-08-03 本機實測 cinc-auditor json <dev-sec tarball 網址>exit=059 條控制項、profile name linux-baseline。CINC 原生就吃 URL。原本那道限制不是技術限制,是決策範圍問題

P5 的原始範圍:FR-059 design P5「平台完全不經手檔案」的理由是 CM-992 限制②——「params.profilesecret:false 純文字,token 塞進 URL 會洩漏到 FE 執行紀錄與稽核 log」。那講的是掃描路徑代管私有 repo 憑證。而抽取是 FR-060 才有的另一個面向,且 dev-sec 兩支是完全公開、不需任何憑證的 GitHub URL。本決策不推翻 P5:URL 憑證/認證代管仍然不做。

選了「BE 代下載」而非「直接把 URL 餵 CINC」:後者看似更省事(少一段程式),但等於把出網整包交給 CINC——我們管不到逾時、大小上限、目標 IP,連它會不會跟隨重導向都不知道。走自家 httpx 才有地方掛防護。

SSRF 防護五道(決策者裁示不做網域白名單,新增來源要改設定的維護成本大於收益):scheme 限 https/解析後每一顆 IP 都不得落私有保留段/IP pinning/50MB 邊讀邊斷/重導向 ≤3 跳且每跳重驗。實作在 common/util/safe_http_fetch.py

🔴 IP pinning 是這裡最容易漏的一道:「解析 → 檢查 IP → 交給 httpx 連 hostname」的寫法,httpx 會自己再解析一次 DNS,攻擊者讓兩次解析回不同結果(DNS rebinding)即可完全繞過檢查。故改為用檢查過的那顆 IP 連線Host header 與 TLS SNI 帶原 hostname,憑證驗證仍對原 hostname 生效(已實測:SNI 給錯會 CERTIFICATE_VERIFY_FAILED,安全性不是靠關掉驗證換來的)。同理「重導向每一跳都要重跑全部檢查」——突變測試證實只驗第一跳的話,302 到 127.0.0.1//192.168.x.x/降級 http:// 三種繞過全部通過。

三條不變式(做錯任一條都會製造更難查的問題):

  1. 掃描路徑完全不動——agent 仍原樣把 URL 交給 cinc-auditor exec_load_profile()build_payload() 零異動(已逐欄比對 git HEAD 證明 payload 相同)
  2. 下載只在記憶體——不寫 upload_filessource_type 維持 urlfile_idsha256 維持 NULL
  3. 下載在 session_scope() 之外——_prepare() 對 url 型只回「待下載的網址」,實際下載由 worker 在兩段 session 之間做,維持既有三段式

被排除:把下載內容存進 upload_files 變成 file 型快照——那會讓 url 型悄悄變成 file 型,破壞 P5 的雙軌語意;更糟的是掃描(agent 自己拉 URL)與抽取(平台存的快照)從此可能是不同的內容,對不上時沒人知道該信哪個被排除:網域白名單——決策者裁示不做(新增來源就要改設定),改以上述五道防護收斂風險。

FE 對應:url 型指向的是 master branch tarball,內容會漂,且沒有 sha256 當信任根。所以 url 型的控制項清單不可以跟 file 型長得一模一樣(否則使用者會拿它當稽核依據),成功態加一條「內容取自外部網址的當下快照,實際掃描時以檢測代理取得的版本為準」+顯示解析時間;file 型不顯示。

未來反悔條件:若出現私有 repo(需要憑證)的需求 → 回到 P5 的憑證代管議題,另案討論,不在本決策範圍內延伸。


3.20 四項不可逆決策

以下四項一旦上線就改不了(或改的成本高到不會有人願意付),是本案最需要現在想對的地方:

# 決策 為什麼事後改不了
D4 uid 由從檔逐列沿用舊值 uid 是凍結契約,已被 job_execution_detection_tools.tool_params(4 筆)與 agent_tasks.params(6 筆)的 JSONB 內容硬指著。搬遷後若要換歸屬,等於要回頭改寫既有 JSONB 資料,且 _profile.uid 在 file 型指的還是 upload_files 的 uid,兩種語意要分開處理,出錯就是派工拿不到檔
D12 + D19 controls 表的欄位形狀(正規欄最小集合 + attributes jsonb + extraction_formatorigin 雙標記) 這是全案最不可逆的決定。上線後要接第二個格式,只能加資料,不能改欄位語意——已經落庫的幾萬列控制項會照舊語意被讀。若第一版就刻 InSpec 專有欄位(impact 浮點),XCCDF 進來時只剩「上線後改表」一條路
D2 當前版指標用 is_current 布林 + partial unique index,不用 FK 當唯一性保證 唯一性保證換寫法要重建索引與改寫所有升版路徑。oscal.frameworks 走過 FK 那條路、官方已認錯廢棄,補救動作至今仍列在待 DROP 清單裡——那就是「事後改」的實際代價
D3 從檔自己掛 RLS(冗餘 tenant_idscopeorg_unit_id 若第一版漏掛,租戶隔離從上線那刻起就是破的,且 file_id 洩漏搭配 FR-058.7 取檔通道是實質的檔案越權路徑。事後補掛要先確認沒有既存的越權存取、還要回頭補冗餘欄位的資料。本專案首例主從雙表 RLS,沒有前例可抄,必須一次做對

4. 現況接入點盤點

拆表不只是 DDL,會牽動既有的 repository、service 與派工鏈。以下是實際 grep 過程式碼後的盤點,不是推估。

4.1 同構前例:oscal.frameworks / oscal.framework_versions

這兩張表在 DEV 不存在,屬 jedi-oscal-v2 套件的表——只能參考程式碼模式,RLS 部分無實例可抄

該抄的模式ON DELETE CASCADE 掛從檔的 profile_id;不用 SQLAlchemy relationship()、純外鍵 int 欄(DDD 不在 infra 層 JOIN);Query Entity 帶關聯欄位;對外只露 uid 不露 id;主檔列表內嵌 versions 之外,版本另有獨立分頁 API;serializer 自參照樹用 lambda:exclude 防遞迴。

該避的坑

  • 不要寫成 N+1 的 _enrich(每列主檔各打一次 list_versions
  • 不要在 Python 端過濾/排序/分頁
  • 🔴 不要新增與既有 marshmallow schema 撞名的 class——已有 500 事故前例(兩個 module 出現同名 schema 造成 RegistryError)。本案要新增的 schema 數量不少(主檔/從檔/控制項/分類值各一組),命名前先 grep 確認

main_version_id 外鍵的失敗史已寫在 D2,不重複。

4.2 拆表的三個實質收益

# 現況問題 拆表後
1 唯一索引是 (detection_tool_id, tenant_id, name)——拿 name 當身分,這是改名改不動的根因 收斂成 (profile_id) WHERE is_current。改名變成主檔一列 UPDATE,版本鏈完全不受影響
2 deactivate_profile 現在要同時降 is_activeis_current(後者純粹是為了不擋同名新版撞索引) 那行整個消失——is_current 不再與 name 唯一性糾纏
3 fork 與 copy_to_tool 是兩套幾乎重複的邏輯 差異收斂成三個參數(scope / tenant_id / tool_id),版本複製邏輯相同 → 抽共用 _clone_profile()(D9)

4.3 🔴 派工鏈兩處破口(本章最重要的一節)

原本以為拆表只影響管理面。實際 grep app/detection_tools/service/detection_orchestration_service.py 後發現兩處會壞,其中一處是無聲的授權漏洞。

🔴 破口 ① — _load_profile() 檢查 profile.is_active

is_active 拆表後在主檔。若 _load_profile() 拿到的是從檔 entity,有兩種壞法:

從檔 entity 的狀態 後果 嚴重度
沒有 is_active 屬性 AttributeError → 500 高,但會被發現
**有但預設 None None is False 為假 → 守門靜默失效,已停用的 profile 照樣派得出去** 更高——不會有人發現

後者是無聲的授權漏洞,比 500 更糟:使用者以為某支基準已經停用不再被掃描,實際上排程照跑。在 GRC 系統裡這是正確性問題。

🔴 破口 ② — _humanize_profile_param()getattr(profile, "name", None)

name 拆表後也在主檔。這裡因為 getattr 帶 default 值所以不會報錯,只會讓通知信裡的「掃描設定」欄位靜默退化成 profile:<uuid>——使用者收到一封通知信,看到的是一串 UUID 而不是基準名稱。

而且目前沒有任何測試會抓到這個退化。 .1 收尾時要補一支測試斷言通知信的參數顯示不是 profile: 開頭的裸字串。

兩處共同的解法:domain service 新增 get_version_with_profile(uid),回傳主檔+從檔合成的 entity(從檔全欄位 + 主檔的 name / is_active / detection_tool_id / 分類三軸),orchestration 的三處呼叫點改指它。

這個解法有兩個好處:對外行為零變化(agent 拿到的 payload 形狀與今天一模一樣);把「派工需要哪些欄位」這件事收斂到一個方法裡——未來再有欄位搬家,只有這一個方法要改,不必再 grep 一輪 orchestration。

4.4 確認零影響的兩處

這兩處已實際確認拆表後原樣可用,實作時不要順手改

位置 為什麼不受影響
common/util/detection_profile_ref.pybuild_payload() 全部讀從檔欄位(source_type / file_id / url / sha256 / version),拆表後這些欄位仍在從檔,原樣可用
agent_file_access_service._profile_ref_resolver 它比對的是 upload_files 的 uid,根本不碰 profile 表。FR-058.7 取檔通道的授權判定與本案拆表正交

4.5 兩條凍結契約的引用不動

依 D4「uid 由從檔逐列沿用」,下列 10 筆既有引用零轉換、零風險

位置 現況筆數 內容
config.job_execution_detection_tools.tool_params JSONB 4 profile:<uid> 字串
compliance.agent_tasks.params JSONB 6 _profile 物件

⚠️ 再次提醒 _profile.uid 的語意依 source_type 而異:url 型是 profile 列的 uidfile 型是 upload_files 的 uid。搬遷驗證查詢比對 uid 時必須分開對待,不要把兩者混在同一個 NOT EXISTS 裡比。


5. 詳細設計

5.1 三張表的欄位定義

欄位歸屬照 D1(判準:「換一版會不會變」)。以下為邏輯欄位清單,實際 DDL 於 .1 的 migration 撰寫時定稿。

主檔 config.detection_profiles

類別 欄位 說明
身分 iduid uid 另生新值(舊 uid 歸從檔,見 D4)
基本資料 namedescription 改名只動這一列,不升版(D7)
分類三軸 target_typetarget_productbenchmark_family 型別軸與體系軸存 enum key(D5),產品軸為自由文字
歸屬 scopetenant_idorg_unit_id RLS 判定來源
關聯 detection_tool_id 「這支基準給哪個工具用」是基準本身的屬性
狀態 is_activecurrent_version_id current_version_id 是查詢便利欄,唯一性保證不靠它(D2)
審計 created_atcreated_userupdated_atupdated_user API 回傳時須 enrich *_user_name(專案審計欄位規範)

唯一索引:(detection_tool_id, tenant_id, name) 仍然保留——同一租戶同一工具下不該有兩支同名基準。但它現在約束的是「基準」而不是「某一版」,改名不再撞版本鏈。

⚠️ id 33 搬遷後會產生 current_version_id IS NULL 的主檔,這是合法狀態(D15),UI 與 serializer 都要能處理。

版本從檔 config.detection_profile_versions

類別 欄位 說明
身分 iduid 🔴 uid 逐列沿用舊表(凍結契約,D4)
關聯 profile_id FK → 主檔,ON DELETE CASCADE
來源 source_typefile_idurlsha256 sha256 是壓縮檔原始 bytes 的雜湊(FR-039 對帳信任根),不是 CINC 自算的 profile 雜湊
版本 versionis_current UNIQUE (profile_id) WHERE is_current partial unique index(D2)
內容衍生 supports 從該版 inspec.yml 抽取,唯讀不可編
抽取 extraction_statusextraction_errorcontrol_countpending_count 四態 pendingrunningsucceededfailed(D10);兩個計數是統計索引,避免列表每次 COUNT(*)
RLS 冗餘 scopetenant_idorg_unit_id 🔴 必須有(D3),policy 與主檔逐字對稱
審計 同主檔

控制項 config.detection_profile_controls

欄位形狀照 D12 + D19:正規欄只收跨格式共通的最小集合,格式差異全進 jsonb。

類別 欄位 說明
身分 iduid
關聯 version_id FK → 從檔,ON DELETE CASCADE
正規欄 control_id 控制項編號(如 twgcb_01_014_0079)。跨版本識別靠它,但不保證穩定(FR-061 定案)
正規欄 titledescription 清單主要顯示欄;descriptiondescriptions.default丟掉與它重複的 desc
正規欄 severity_rawseverity_norm 🔴 不叫 impact——那是 InSpec 專有的 0.0–1.0 浮點語意。severity_raw 原樣存來源值(文字),severity_norm 是跨工具可比的統一級距
正規欄 is_pending 人工待判標記。判準寫在各格式的 extractor 裡(InSpec 是 impact == 0.0),不是寫死在表結構的假設裡
正規欄 source_ref 🔴 剝掉絕對路徑前綴後的相對路徑(controls/xxx.rb)。不剝會存進無意義且洩漏內部路徑的字串
標記欄 extraction_format 哪個格式抽出來的(inspecxccdf/…)
標記欄 origin extractedcustom(FR-061 自訂項用)。🔴 extraction_format 正交,兩者不可合併成一個欄位
其餘 attributes jsonb InSpec 放 tagsdescriptions;XCCDF 未來放 identfixtextrationale。為常用 key 建 GIN 索引(至少 category,供列表 rowGroup 分組用)

attributes 整包存 jsonb 的實測依據:tag key 集合隨 profile 變動——TWGCB-01-014 的 twgcb_idrolecategorygpo_pathgcb_valuecheck_type 各 234/234、status 199/234;TWGCB-01-011 多出一個 service_display_name(368/708)。客戶自帶的外來 profile 更不可能有 TWGCB 那套 key,schema 本來就不可寫死成固定欄位

期望值兩條路都拿得到(tags.gcb_valuedescriptions.gcb_value,本 profile 兩者一致),以 tags 為主、descriptions 為 fallback

5.2 分類 enum 表(因 D5 改存 DB 而新增)

決策者裁示「DB 只存 key、由前端翻譯」,因此這張表要盡可能薄——不存顯示名稱、不開翻譯表。

表名建議 config.detection_profile_taxonomies(一張表容納兩個受控軸,用 axis 欄位區分,避免為兩個軸各開一張幾乎相同的表)。

欄位 型別 說明
iduid
axis text 哪一軸:target_typebenchmark_family標的產品是自由文字,不進這張表
key text 純英數 slug,如 osbrowserdatabasetwgcbcisstig這是唯一會被 profile 引用的值
sort_order int 下拉與分組的顯示順序(分組時「未分類」放最後,D17)
is_active bool 停用後不出現在新建/編輯選項,但既有引用仍解得出 key(見下方刪除保護)
審計欄位 同其他表

唯一索引 (axis, key)這張表是全域的(不分租戶)——分類骨幹由 root 統一維護,租戶不得增刪,否則篩選語意會漂移。因此不掛 RLS,比照專案內其他全域字典表的做法。

刪除保護(必做)

已被 profile 引用的分類值不可刪。刪掉之後既有 profile 的 target_type 會指向一個不存在的 key,列表篩選與下拉分組都會出現無法解釋的空白,且前端 fallback 只能顯示原始 key。

實作上兩層:

  1. 應用層:刪除前 COUNT(*) 查引用數,非 0 直接擋下並回明確錯誤(引用數要一起回給前端,讓 root 知道擋在哪)
  2. 偏好做法:根本不提供刪除,只提供停用is_active = false)——停用不影響既有引用的解析,語意也更誠實:分類骨幹是歷史紀錄的一部分,本來就不該被抹掉

不要用 FK 約束來做這件事——分類值是 soft-ref(profile 存的是 key 字串不是 id),改成 FK 會牽動搬遷 SQL 與 D17 的部分補登(留 NULL 的那些)。應用層擋下即可。

維護介面的落點與但書

需要一個 enum 值 CRUD 的落點(列表/新增/改 key 排序/停用),權限限 root,走 common/authz/主體域守門(platform-admin 軸,允許 route 層 decorator 形式)。

若 .2 的範圍評估後太肥,可先只做「讀 DB + 靠 migration seed 維護」——但必須在 FR-060 的 spec 明寫「分類值目前仍靠 migration 維護,尚未有管理 UI」。D18 已有前例教訓:FR-059 預留了 field_scopehint_scope_system 兩個 i18n key 卻沒做 UI,後來沒人知道那兩個 key 為什麼在那裡。不要製造第二個懸空孤兒。


5.3 RLS:兩表八段

現行 config.detection_tool_profiles 的四段 policy 結構,是本案要複製到兩張表上的樣板:

Policy 邏輯
SELECT scope = 'SYSTEM' 人人可讀(此分支放最前),其餘走租戶隔離
INSERT scope <> 'SYSTEM'——防租戶偽造公版
UPDATE (WITH CHECK) scope <> 'SYSTEM'——防租戶把自己的 profile 竊升成公版
DELETE scope <> 'SYSTEM'

主檔與從檔各掛一套,共八段,policy 內容逐字對稱。控制項表不掛。

為什麼是兩表而不是三表:判準是「會不會被當作獨立查詢入口」

掛不掛 判準
主檔 detection_profiles 有租戶身分,且是列表頁的主要查詢入口
從檔 detection_profile_versions 它是獨立入口——派工主幹道 _load_profile() 走的是從檔的 get_by_uid(uid)拿著一個 uid 就能查、不經過主檔。它自己就是一張帶租戶身分的表
控制項 detection_profile_controls 不掛 它永遠是被帶出來的——任何查詢都得先有 version_id,而 version_id 只能從已受 RLS 保護的從檔取得。沒有繞過從檔直接觸及控制項的路徑

專案既有慣例佐證(實查 DEV 全庫):本專案 RLS 一律掛在有租戶身分的主體表那一層,純附屬明細表不掛,且明細表通常連 tenant_id 欄位都沒有。實例:oscal.catalog_controlsoscal.poam_itemsoscal.assessment_findingsoscal.ar_resultsoscal.ssp_inventory_itemssurvey.task_survey_ref_itemssurvey.question_answer_history_details——九張明細表,零張掛 RLS

其中 oscal.catalog_controls 與本案的控制項表是同一種東西(控制項明細),它就是不掛。本案跟進既有形狀,不開新做法。

判準寫成規則(未來新增子表時據以判斷):

本專案 RLS 掛在「有租戶身分、且可能被直接查詢」的主體表;純附屬明細(必須經主體帶出)不掛,也不加冗餘 tenant_id

🔴 從檔一定要自己掛 RLS

PostgreSQL 的 RLS 不沿外鍵繼承。 只掛主檔的話,SELECT * FROM 從檔 完全不受限制 → 租戶 A 能列出租戶 B 的全部 sha256file_idurl

file_id 洩漏搭配 FR-058.7 取檔通道,就是一條實質的檔案越權路徑。

而且從檔會被直接查——派工主幹道 _load_profile() 走的就是從檔的 get_by_uid,不是先查主檔再 join。

⚠️ helper 型別 cast 陷阱:八處漏一處就硬失敗

app_tenant_allowed_for_session(integer) 只接 integer,而 tenant_idbigint → policy 內必須寫 tenant_id::integer

四段 policy × 兩張表 = 共 8 處,漏任何一處都會在套 migration 當下硬失敗。這個失敗是明顯的(不是靜默),但會讓整支 migration 回滾重來,撰寫時逐處對過。

⚠️ 本專案首例:主從兩表都帶 RLS

目前沒有任何「主從兩表都帶 RLS」的前例,FR-060 是首例,沒有既有實作可抄。因此 migration 內必須附上跨租戶讀取的驗證查詢——以 cm_app 身分(受 RLS)分別模擬租戶 A 與租戶 B,確認:

  1. 租戶 A 讀不到租戶 B 的從檔列(這是最關鍵的一條,主檔擋得住不代表從檔擋得住)
  2. 兩者都讀得到 scope = 'SYSTEM' 的公版
  3. 租戶 A 無法 INSERT/UPDATE 出 scope = 'SYSTEM' 的列

冗餘欄位的代價(D3 已述):改主檔的 scopetenant_id 時要同步更新從檔。實務上這兩者建立後幾乎不變(fork 是建新列而非改欄位),代價很低;集中在 app service 層處理,不要散在各處

5.4 三段式遷移路徑

全程在 psql --single-transaction -v ON_ERROR_STOP=1 內原子完成。十個步驟:

# 步驟 要點
CREATE 主檔 detection_profiles current_version_id 欄位建好,FK constraint 先不加(循環相依)
CREATE 從檔 detection_profile_versions profile_id FK 立即生效;冗餘 tenant_idscopeorg_unit_id
CREATE 控制項表 detection_profile_controls version_id FK,ON DELETE CASCADE
INSERT 主檔 SELECT DISTINCT (tool_id, tenant_id, name)current_version_id 留 NULL
INSERT ... SELECT 從檔 🔴 JOIN 條件三欄缺一不可;🔴 uid 逐列沿用舊表
UPDATE 主檔回填 current_version_id 依從檔的 is_current
ALTER TABLE 主檔 ADD FK current_version_id → 從檔 循環相依在此收口
建 RLS:四段 × 兩表 = 8 段 tenant_id::integer cast 逐處確認
RENAME 舊表 → detection_tool_profiles_deprecated_20260803 不 DROP(D16)
驗證查詢,全部通過才 COMMIT 見下

⑩ 的驗證查詢(缺一不可):

  • 主檔 = 13 筆
  • 從檔 = 13 筆
  • current_version_id IS NOT NULL = 12 筆(不是 13——id 33 那條是合法的 NULL,D15)
  • 🔴 uid 零遺失:以 NOT EXISTS 比對舊表全部 uid,結果必須是 0
  • 跨租戶讀取驗證(§5.3 的三條)
  • 收尾 INSERT public.schema_migrations

🔴 搬遷 JOIN 條件必須是 (tool_id, tenant_id, name) 三欄

DEV 的 id 32 與 33 同 tenant、同 name(twgcb-02-003-google-chrome)、同 file_id,但屬於不同工具(tool 8 vs tool 4)——是使用者選錯工具後停用重建留下的痕跡。

少一欄,32 與 33 會 cross join,搬出錯誤的主從關係。 而 STG 測不到這個陷阱(全 SYSTEM、無此形態資料),這條只有在 DEV 才驗得出來

為什麼不用 DEFERRABLEINITIALLY DEFERRED 會讓應用層每一次寫入都延到 commit 才檢查外鍵,錯誤發生點離現場很遠、極難除錯。而應用層「先 INSERT 從檔 → 再 UPDATE 主檔指標」的順序本來就自然,不需要 defer。遷移期間的循環相依用「先不加 FK、最後 ALTER 補上」解決即可。

⚠️ 索引命名一律用新前綴

RENAME TABLE 不會自動改索引名稱——舊表的索引名會原封不動留在 _deprecated 表上。新表的索引一律用新前綴 idx_dp_*idx_dpv_*idx_dpc_*,避免與舊表殘留的索引名撞名導致 migration 中途失敗。

舊表 DROP 另開一支 migration,觀察期(建議一個 release 週期)後執行,且比照 CLAUDE.md「DROP 退役表前安全四查」實際 grep 確認沒有 live-wired 的 code 還在引用(D16)。


5.5 抽取器註冊表(D19 的擴充骨架)

管線不寫成「呼叫 cinc-auditor」,寫成「依格式取 extractor」。三層結構:

① 工具端宣告能力 — 沿用既有慣例,不發明新機制。detection_tool_param_schemas 已有 options_source: profile_library 這個「這個工具的設定檔走庫」的宣告,把它補完整:

新增宣告欄 值域 用途
profile_format inspecxccdf/… 決定取哪個 extractor
supports_extraction bool 這個工具的 profile 能不能抽內容

新工具入庫 = 加一列宣告,不動任何表。

② 抽取器註冊表 — 依 profile_format 取對應 extractor 類別。新格式 = 新增一個 extractor 類別 + 註冊一行,不動管線骨架、不動既有工具路徑。

介面最小集合:吃「壓縮檔臨時路徑」,吐「控制項列表 + profile 層中繼資料(supports 等)」,以及三種結果分支之一。本期只實作 InSpec 一種。

附帶效益:BE 裝 CINC 這項主機依賴只綁 inspec 格式,不會變成全平台前提——未來 XCCDF extractor 進來時,沒裝 CINC 的環境仍可處理 XCCDF profile。

③ controls 表存正規化語意 + 原始中繼資料分離 — 已在 §5.1 定義。

代價要講清楚:現在只有 InSpec 一種真實格式,正規化是「照一個實例抽象」,有抽錯風險。降低風險的做法是正規欄只做最小集合——不要為了預想中的 XCCDF 去猜欄位。寧可多留在 attributes 裡,日後有第二個實例驗證後再提升為正規欄(那是加欄位,不是改語意,成本可接受)。

CINC 路徑解析

比照 LibreOffice 慣例(docs/claude/host-dependencies.md:85-89)三層解析:

  1. 環境變數 CINC_AUDITOR_CMD
  2. PATH
  3. 絕對路徑 fallback

🔴 不要硬編 /usr/bin/cinc-auditor——macOS 開發機路徑不同。

⚖️ 授權紅線

必須是 CINC Auditor(Apache 2.0)絕不可換成官方 InSpec 6+ 商業 binary(需接受 Chef EULA 並取得 license key);inspec-core gem 由 Chef 官方發布、同樣受 EULA 影響,也不可用

安裝:curl -fsSL https://omnitruck.cinc.sh/install.sh | bash -s -- -P cinc-auditor -v 7

成本:體積 275MB(其中約 200MB 是本案用不到的 AWS/Azure/GCP SDK 與遠端 transport),三台部署機加每台開發機都要裝,收尾要在 docs/claude/host-dependencies.md 新增第三項(現有兩項是中文字型與 LibreOffice)。

5.6 抽取管線的三種結果分支

壓縮檔 bytes 落成臨時檔(tempfile.NamedTemporaryFile(suffix=info.ext)副檔名必須正確,CINC 靠副檔名判斷格式)後交給 extractor。全程零 extract() 呼叫——不解壓成目錄樹,與 FR-059 T-1.3「不落地解壓」驗收條件不衝突(該條件禁止的是解壓成目錄,那是 zip-slip 可能寫出惡意路徑的時刻)。

分支 判定 處理
正常 exit == 0controls 非空 落庫,succeeded
明確失敗 exit != 0,stderr 有明確訊息(六種實測形狀:無 inspec.yml/空目錄/路徑不存在/YAML 格式壞/檔案無副檔名等) failed,記錄 stderr 到 extraction_error
🔴 靜默失敗 exit == 0、stderr 全空、JSON 合法,但 controls == [] 也必須判 failed,訊息「未解析到任何控制項,請確認 profile 內容」

🔴 第三種分支:只看 exit code 會漏

.rb 控制項檔有 Ruby 語法錯誤時,cinc-auditor jsonexit 0、stderr 全空、輸出合法 JSON、但 controls 是空陣列——整個檔案的控制項(包含語法正常的那些)全部被靜默丟棄。

若只看 exit code,使用者會拿到一支「抽取成功但零控制項」的基準,他會以為是這份基準本來就沒東西,而不是解析壞了。靜默的錯誤比明顯的錯誤更貴。

誤判風險:理論上存在「合法但零控制項」的 profile(純 wrapper profile 只有 depends 沒有自己的控制項),但那種 profile 在本系統的使用情境下也是無效的(掃了什麼都不會驗)。判成失敗是正確的。

落庫前的解析動作:剝掉 source_location.ref 的絕對路徑前綴、丟 code(省 47.8%)、丟與 descriptions.default 重複的 desc(合計省 59.6%)、impact == 0.0 標記人工待判。收尾刪除臨時檔。

耗時參考(實跑,非推估):234 條約 28–29 秒(704 KB → 約 292 KB)、708 條約 117 秒(1.82 MB → 約 870 KB)。117 秒遠超任何 HTTP 逾時設定,非同步沒有折衷空間。

5.7 API 端點增修

方法 端點 變更 說明
GET /detection-tool-profiles 改語意 一列 = 一支基準(主檔),內嵌當前版摘要 + 控制項統計;篩選加分類三軸
PUT /detection-tool-profiles/<uid> 新增 編輯基本資料(名稱/描述/分類三軸),不升版(D7/D8)。消費既有但至今無人使用的 detection-profile.update capability
GET /detection-tool-profiles/<uid>/versions 新增 版本歷史獨立分頁 API(列表已內嵌摘要,此端點供版本多時使用)
GET /detection-tool-profile-versions/<uid>/controls 新增 某一版的控制項清單。回全量供前端快取(控制項是靜態內容,不做 server-side lazy loading)
GET /detection-tool-profiles/menu 維持扁平(D13) [{value, name, target_type, target_product, benchmark_family, ...}],分組由 FE 自己 groupBy
GET /detection-profile-taxonomies 新增 分類 enum 值供給(依 axis 篩選),只回 key + 排序

PUT 端點的權限規則:租戶只能編自己的(scope = TENANTtenant_id 相符);公版(scope = SYSTEM)只有 root 能編。此判定屬資源域守門(要先 resolve 資源才知道判誰),走 common/authz/app service 層執行,不要另立 helper

🔴 menu API 不可改成巢狀

JobExecutionDrawer.vue:279-282for (const m of items) 若拿到巢狀會靜默失效(不 throw、不進 catch)——任務抽屜的參數顯示直接退回裸的 profile:<uuid>,使用者看到一串 UUID。

分組交給 FE 還有兩個好處:FE 想改用哪一軸分組不必動 BE;符合 CLAUDE.md 的 Menu pattern(回傳 List[MenuDto] 不包 PageDataDto)。

🔴 FE 必配的 fallback 修補(不論 BE 回什麼形狀都要做)

DetectionConfigField.vue:82-92 有一段 opts.some(o => o.value === current),用來判斷「目前選中的值是否還在選項裡」,不在就補一個「已不可用」的 fallback 項。

選項在 FE 端改成巢狀後這個比對必然全部 miss(它比對到的是 group 物件不是 leaf 選項)→ 每一個已選值都會多長一個「已不可用」項。不會報錯、不會進 catch,只會讓每張已設定的任務都顯示成「這個 profile 已不可用」。

對策:比對前先攤平選項樹;fallback 項本身也要包成一個群組,否則會混在群組之間造成渲染錯亂。


6. FR-061 模型契約

本案不實作選用範圍、豁免、人工判定與自訂項,但資料模型必須現在就留好掛載點——這些是不可逆的架構決定,事後再改要動 FR-061 已寫好的東西。

6.1 四條硬約束

FR-061 能力 對 FR-060 模型的要求
豁免(某條控制項對某單位不適用) 豁免要能綁定單一控制項控制項必須是一列可被外鍵參照的資料,不能是 jsonb 陣列裡的元素。這是 D12 選擇「一條一列拆表」的決定性理由
選用範圍(這個單位只驗這些條目) 選用範圍綁主檔不綁版本——單位的適用範圍是跨版本有效的政策決定,不該因為上傳新版就消失。但範圍內的條目清單要能對應到具體控制項,因此需要「控制項在跨版本間可識別」的機制(control_id 字串在同一支基準的不同版之間通常穩定,但不保證;此點留給 FR-061 定案)
人工判定(199 條待判項的處理結果) 判定結果要能接上既有的證據鏈——判定記錄要能引用 evidence/SSP 程序書,形狀比照現有的證據關聯。控制項表要有穩定的主鍵讓判定記錄指過來
自訂項(單位自己加的檢查條目) 控制項表要能容納「不是抽取來的」列 → 來源標記欄 origin = extracted | custom,且重新抽取時不可清空自訂項。🔴 注意這與 D19 的 extraction_format(哪個格式抽出來的)是兩個正交的標記,不可合併成一個欄位

🔴 抽取欄位必須一次抽完整

不是抽「目前要顯示的子集」,是抽「CINC 給的全部(扣掉 code 與重複的 desc)」。

如果 FR-060 只抽 idtitledesc 三欄,FR-061 做豁免時發現需要 tags、做人工判定時發現需要 descriptions.gcb_value,就得把所有 profile 全部重抽一次——每支 28~117 秒,而且要寫一支資料回填 migration。

存下來不顯示,成本是幾百 KB;沒存要重抽,成本是一整輪重跑加一支 migration。

D19 讓這條約束更強:重抽的成本不只是時間——多工具之後,某些格式可能根本無法重抽(工具端 API 已改版、或原始檔已不在)。attributes 整包存下來是唯一保險。

6.2 InSpec 原生機制對照(FR-061 實作時的落點)

FR-061 的三種能力在 InSpec / CINC 都有原生對應,不需要自己發明機制——這也回頭確認了 FR-060 該抽哪些欄位:

能力 InSpec 原生機制 細節
選用範圍 --controls / --tags 執行時以參數限縮要跑的控制項。這是 tags 必須完整存下來的直接理由——沒有 tags 就無法用 tag 篩選
豁免 --waiver-file 欄位:control_id(必填)/justification(必填)/expiration_daterun豁免要有理由與到期日是 InSpec 原生設計,FR-061 照做即可,不必自創欄位
自訂項 wrapper profile dependsinclude_controlsskip_control——用一支 wrapper profile 引用原基準並增刪條目。這條路徑意味著 FR-061 可能要產生 profile 而非只是傳參數,複雜度較高,是它單獨成案的理由之一

6.3 已識別但本期不做:公版名稱/描述的多語化

決策者裁示(2026-08-03):

D20 我不想把需求拉複雜,名稱跟描述之後再補吧,目前就先選單類型的做就好。

本期不做,但缺口已經識別出來,記在這裡免得半年後有人重新發現一次。

現況缺口(不是未來的問題,是現在就存在的):公版(scope = SYSTEM)的名稱與描述是我們自己 seed 的中文字面值,英文語系租戶登入後看到的仍是中文。實例是 DEV 那 10 筆公版,名稱形如 TWGCB-01-014 Ubuntu 22.04 LTS v1.2(35 項自動檢查)dev-sec Linux 基準(公開範例,適用 Linux 目標)

為什麼本期不做:決策者裁示不擴大範圍。這件事牽涉 root 編輯權限、seed 流程、前端 i18n 檔補齊,是完整一條線;混進 .2 會讓「分類與編輯」這個子項失焦。

未來要做時的形狀(先寫下來,免得屆時重新設計一次):

  • 主檔加 name_i18n_key / description_i18n_key 兩個 nullable
  • 公版才填、租戶自建留 NULL
  • 顯示走與 D5 相同的 fallback:有 key 且翻譯存在 → 顯示翻譯,否則顯示 name 字面值
  • 🔴 name 欄一律保留字面值不變——搜尋(DetectionProfileManageView.vue:506-513 的 IconField 搜尋)與排序都在 SQL 端吃這個欄位,改成純 key 會讓搜尋與排序失效
  • 這是純追加欄位、不改既有語意,符合 D19「擴充不動已上線的東西」的精神

與 D5 的區別(同一個問題在不同欄位有不同答案)

分類 enum 可以只存 key——值域有限、只被下拉選取與分組使用、沒有人會打字搜尋分類值

名稱不行——它會被搜尋、會被排序,兩者都在 SQL 端執行。存成純 key 會讓搜尋框搜不到中文名稱、排序變成按 slug 字母序。

理由是存取方式不同,不是標準不一致。未來看到這兩處做法有別,是刻意的。


7. 階段拆分

切線在管理面/執行面之間,不是在「BE/FE」之間。

FR-060
 .1 資料模型重構    主從拆表 + controls 表 + 13 筆搬遷 + RLS 移位
                    + repo/service 改寫 + 派工鏈兩處破口修補
 .2 分類體系 + 基本資料編輯(含改名)
 .3 控制項抽取 + 瀏覽    BE 裝 CINC + 非同步抽取管線 + 詳細頁
 ─────────  以上第一批:全在管理面,不碰 agent、不動派工參數
 .4 選用範圍 + 豁免      (FR-061,本案只定模型契約)
 .5 人工判定 / 自訂項    (FR-061,待 .1-.3 上線後看實際使用再定形狀)

順序:.1 → .2 ∥ .3
子項 範圍 依賴與風險
FR-060.1
資料模型
主從拆表 + 控制項表建立(欄位採 D19 跨格式形狀)+ 13 筆三段式搬遷 + RLS 兩表八段 + repository/domain service/app service 改寫 + 派工鏈兩處破口修補get_version_with_profile() 全案的地基,必須先行。 風險最高的一項——rename 的是 STG 正在服役的表,且 STG 資料全 SYSTEM 測不到租戶路徑。驗收必須在 DEV 完成。controls 表欄位是本案最不可逆的決定(D19)
FR-060.2
分類與編輯
分類三軸落地(D5)+ 分類 enum 表 + seed + 刪除保護 + root 維護介面(D5 改存 DB 後新增的工作)+ platform_hint 退役(D6)+ 編輯端點(D8)+ root 建公版 UI(D18)+ 管理頁重構 + 下拉分組 + fallback 比對修補(D13)+ 既有 13 筆分類補登(D17) 依賴 .1 的主檔存在。與 .3 可並行(.2 動主檔與 UI 框架,.3 動從檔與詳細頁)。⚠️ 範圍比原討論稿估的更肥——D5 改存 DB 後多出 enum 表、刪除保護、維護介面三塊;FE 還要補 i18n 條目(否則新值以原始 key 顯示)。若排不下,維護介面可退為「migration seed + spec 明寫尚未有 UI」,但 enum 表與刪除保護不可退
FR-060.3
抽取與瀏覽
BE 主機安裝 CINC(含 host-dependencies.md 補第三項)+ 抽取器註冊表骨架(D19)+ InSpec extractor 實作(含三種失敗分支)+ 非同步管線 + extraction_status 輪詢 + 控制項清單詳細頁 + 摘要區(234/199/35) 依賴 .1 的控制項表存在。主機依賴要提前備妥——三台部署機 + 每台開發機都要裝,這是實作前的準備動作不是實作內容。本期只實作 InSpec 一種 extractor
FR-061.4 / .5
下一階段
選用範圍/豁免/人工判定/自訂項 另案。本案只定模型契約(第 6 章),不實作

為什麼切線在這裡:.1–.3 最壞的情況是「詳細頁少一塊內容」——既有掃描零影響,因為這三項完全不動掃描參數。而 .4 開始就動掃描參數(--controls--tags--waiver-file),改錯會讓掃描結果不正確。在 GRC 系統裡,使用者以為自己合規但實際不是,是嚴重問題——這種風險等級的變更值得單獨立案、單獨驗收。

7.1 實作前的準備動作

  1. BE 主機裝 CINC Auditor——三台部署機 + 每台開發機,先確認 macOS 開發機的路徑解析可行(CINC_AUDITOR_CMD 環境變數)
  2. POC 環境唯讀確認——本次未查(環境紀律),需確認 config.detection_tool_profiles 在 POC 的存在狀態與資料筆數,作為未來上版的基準
  3. Notion 開卡,關聯 FR-059 與 CM-992

8. 端到端驗收條件

兩組分開:DEV 組是開發期間必須逐項打勾的;上版前組是決策者放行 STG/POC 之前的守門,兩組不可混淆

🔴 「STG 跑得順」不能當驗收依據

STG 與 DEV 同為 125 筆 migration、FR-059 全 5 支都在,但 STG 的 10 筆資料全部是 SYSTEM、沒有任何 TENANT 資料。這意味著搬遷 SQL 在 STG:

  • 測不到租戶 RLS 路徑(全公版,租戶隔離那段 policy 根本沒被走過)
  • 測不到 id 32/33 的三欄 JOIN 陷阱(同 tenant、同 name、同 file_id 但屬不同工具的形態,STG 沒有)
  • 測不到停用列造成的孤兒主檔current_version_id IS NULL 的合法狀態)

驗收必須在 DEV 完成——那裡才有完整的資料形態(10 SYSTEM + 3 TENANT)。

8.1 DEV 驗收(開發期間逐項打勾)

搬遷正確性

RLS(本專案首例主從雙表,必須逐條驗)

派工鏈迴歸(兩處破口)

抽取管線(三種分支各驗一次)

分類與編輯

前端

8.2 上版前條件(決策者放行才執行)

開發期間所有 migration 只套 DEV。 STG(已服役、有 10 筆資料)與 POC(等同 production,對外 demo/客戶試玩)一律等決策者當次明確指示才可套。

8.3 觀察期後另案