功能編號: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 掛載判準(控制項表不掛,補既有慣例佐證);記錄公版名稱/描述多語化為已識別未實作項 |
FR-059 解決了「profile 怎麼進系統」——把掃描設定檔打包收進平台庫、由 agent 拉檔執行。但平台把 profile 當成不透明的二進位檔,完全不理解它的內容:使用者在管理頁與任務下拉看到的只有一個名稱(例如「TWGCB-01-014 Ubuntu 22.04 LTS」),不知道它會驗什麼、驗幾項、哪些項目其實跑不出結果。
決策者原話(2026-08-03):
目前只有項目,user 完全不知道這個東西會驗證什麼,我怎麼知道我要選什麼?哪些基準可以參考,我可以先請負責人去判斷、提前先處理,而不是等報告出來才去處理。
拿系統內最常被選用的 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 條人工待判項如果在選擇當下就看得到,負責人可以提前展開作業;等報告出來才知道,等於白等一輪掃描。
| # | 需求 | 內容 |
|---|---|---|
| ① | 瀏覽 profile 檢驗內容(核心) | 打開一支基準,看得到控制項清單:編號、標題、說明、期望值、分類標籤、是否為人工待判項。使用者要能在選擇之前判斷「這份基準適不適合我」「我要先請誰去準備什麼」 |
| ② | 修改基本資料(含改名) | 租戶可以改自己上傳的 profile 名稱與描述;公版(scope = SYSTEM)只有 root 能改。現況是改不動的——名稱參與唯一索引,唯一的改名途徑是複製一份新的(fork 時順便改名) |
| ③ | 分類體系 | 決策者指出兩件事:CINC 的 profile 不只作業系統,連瀏覽器都有(還有資料庫、容器等);以及 profile 一多,下拉會很長、會選錯。需要一套分類軸讓下拉分組、列表篩選 |
下一階段(FR-061)才做的是對基準內容動手的能力:選用範圍、豁免、人工判定、自訂項。本案只定義「資料模型必須支撐什麼」,不做實作——這些能力直接改變掃描參數,改錯會讓結果不正確,必須等瀏覽與分類上線、看到實際使用情形再定形狀。
決策者在討論過程中明確要求:「你要拿出你專業設計師的模式,而不是每次都用最快達到要求的模式把功能做出來,底層設計很重要。」
三個需求裡有兩個被現行資料模型直接卡住,第三個無處落腳:
(detection_tool_id, tenant_id, name)。改名會撞索引、會讓版本鏈斷開。因此本案第一步是主從拆表:
detection_profiles 基準主檔:名稱 / 描述 / 分類三軸 / 範圍 / 所屬工具 / 啟用狀態
└─ detection_profile_versions 版本從檔:來源檔 / sha256 / 版號 / 當前版旗標 / 抽取狀態
└─ detection_profile_controls 控制項:一條一列,跨格式正規欄 + attributes jsonb
而且現在是最便宜的時機:DEV 僅 13 筆資料(10 筆 SYSTEM/3 筆 TENANT)、FR-059 上線才兩天、沒有租戶真的在用。等 FR-061 把選用範圍與豁免綁上去再拆,成本是現在的好幾倍。
使用者上傳 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 == 0 且 controls 非空 |
落庫,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.0199 ✅ 正確。InSpec 原生語意,任何 profile 都有 code含 skip199 ✅ 正確。與上者同一集合 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',但實際上impact為 0.0 且程式碼只有一個 skip——它們是貨真價實的人工待判項,只是 tag 標錯了。正解是
impact == 0.0——這是 InSpec 原生語意(impact 0代表這條不做自動判定),客戶自帶的外來 profile 也一定有這個欄位,實測 100% 準確。不要依賴任何自訂 tag 做這個判定。(依 D19,此判準寫在 InSpec extractor 內,不是寫死在表結構的假設裡。)
本案 .1–.3 全在管理面,agent 拿到的 payload 形狀與今天一模一樣:
common/util/detection_profile_ref.py 的 build_payload() 全部讀從檔欄位,拆表後原樣可用。agent_file_access_service._profile_ref_resolver 比對的是 upload_files 的 uid,根本不碰 profile 表。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 列的 uid、file 型是 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;從檔有但預設None→None is False為假 → 守門靜默失效,已停用的 profile 照樣派得出去。後者不會有人發現,比 500 更糟。破口 ② —
_humanize_profile_param()用getattr(profile, "name", None)name拆表後在主檔。這裡有 default 值所以不會報錯,只會讓通知信裡的「掃描設定」靜默退化成profile:<uuid>,而且目前沒有任何測試會抓到這個退化。解法:domain service 新增
get_version_with_profile(uid),回傳主檔+從檔合成的 entity,orchestration 的三處改指它。對外行為零變化,且把「派工需要哪些欄位」收斂到一個方法裡。
開發期間所有 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),密碼請查 .env 的 DB_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 完成——那裡才有完整的資料形態。
以下每一項都含決策內容/定案理由/被排除方案與原因。被排除方案不可省略——未來要反悔時,必須看得到當初排除了什麼、為什麼。
決策內容:抽取工作在 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)。
inspec-core gem 由 Chef 官方發布、同樣受 EULA 影響,也不可用。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 筆既有資料。代價全付了,換來的性質卻更差。
決策內容:
detection_profiles:name/description/分類三軸(標的類型/標的產品/基準體系)/scope/tenant_id/org_unit_id/detection_tool_id/is_active/current_version_iddetection_profile_versions:source_type/file_id/url/sha256/version/is_current/supports/extraction_status/控制項統計索引(總數/待判數)定案理由:判準一句話——「換一版會不會變」。會變的進從檔,不會變的進主檔。supports(執行平台)放從檔,是因為它從該版的 inspec.yml 抽出來,不同版可能不同。detection_tool_id 放主檔,因為「這支基準給哪個工具用」是基準本身的屬性,行為與現行零變化。
被排除:分類放從檔——那等於每次上傳新版都要重填一次分類,且同一支基準的不同版可能分到不同類,篩選結果會前後矛盾。
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 完全不碰它)。
決策內容:從檔冗餘存 tenant_id/scope/org_unit_id,policy 與主檔逐字對稱(四段 × 兩表 = 八段)。
定案理由:
SELECT * FROM 從檔 完全不受限制 → 租戶 A 能列出租戶 B 的全部 sha256/file_id/url。file_id 洩漏搭配 FR-058.7 取檔通道,就是一條實質的檔案越權路徑。_load_profile() 走的就是從檔的 get_by_uid,不是先查主檔再 join。detection_executions、job_execution_detection_tools 等子表全部是冗餘欄位形狀,跟進既有形狀降低理解成本。冗餘的代價:改主檔的 scope/tenant_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 內必須附上跨租戶讀取的驗證查詢。
決策內容:舊表那 13 個 uid 逐列沿用給從檔;主檔另生新 uid。
定案理由:舊 uid 是凍結契約——job_execution_detection_tools.tool_params 的 4 筆 profile:<uid>、agent_tasks.params 的 6 筆 _profile 都指著它。從檔沿用等於這 10 筆既有引用零轉換、零風險;語意也對——派工綁的本來就是「某一版」不是「某支基準」。
被排除:主檔沿用舊 uid——那 10 筆引用全部要改寫(改 JSONB 內容),而且語意變成「派工綁基準」,與已拍板的「鎖版」相牴觸。
決策內容:
| 軸 | 型態 | 值域 |
|---|---|---|
| ① 標的類型 | 受控 enum(值存 DB) | 作業系統/瀏覽器/應用程式/資料庫/網通設備/雲端服務/容器/其他(DB 存的是 key,此處為前端翻譯後的顯示樣貌) |
| ② 標的產品 | 自由文字 | Ubuntu 22.04、Google Chrome、PostgreSQL 15 …(前端 autocomplete 既有值) |
| ③ 基準體系 | 受控 enum(值存 DB) | TWGCB/CIS/STIG/dev-sec/自訂(同上,DB 存 key) |
| (④ 執行平台) | 不是第四軸 | 從 inspec.yml 的 supports 自動抽取、唯讀不可編——它是事實不是分類 |
enum 值來源定案:存 DB,由 root 維護(🔴 決策者 2026-08-03 推翻原「FE 寫死」建議)。
決策者原話:
D5 存 DB 不要寫死,這類最好都要可以維護。
定案理由:新增一個標的類型不該需要發版。原建議把八個類型視為「穩定的分類骨幹」,但這個前提在 D19 之後不成立——多工具擴充上線後,新格式帶進來的標的類型只會更頻繁(XCCDF 系的框架涵蓋面與 InSpec 系不同),每次都要發一版是不合理的維運模式。標的產品維持自由文字而非 enum,是因為產品名稱長尾極長(客戶自帶的 profile 可能是任何東西),硬做 enum 會逼使用者選「其他」,反而失去篩選價值。
被排除:FE 寫死常數——這是原討論稿的建議。排除理由是「要新增類型就得發版」,與 D19「擴充點一律是加資料、不動已上線的東西」的精神不一致:D19 才剛把工具與格式的擴充從「改表」降級成「加一列宣告」,分類 enum 卻要改常數再發版,兩者標準不一。 被排除:標的產品做成 enum——理由同上,長尾會讓 enum 失去篩選價值。
原討論稿以「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 為什麼在那裡 |
分類值改由 DB 供給 key 後,前端顯示邏輯直接照抄 DeviceManage.vue:36-57 的三層 fallback(那 28 行面對的是同一個問題:自由文字欄位要枚舉化、同時相容既有資料):
te() 測試)→ 顯示翻譯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。
platform_hint 退役決策內容:退役 platform_hint 欄位。拆表時不搬到新表,值隨舊表一起保留在 rename 後的 _deprecated 表裡。不要在新表建一個空的 platform_hint 欄位。
定案理由:它的功能被 D5 三軸完全覆蓋而且更精確——「這支基準是給什麼用的」由標的類型加標的產品表達,「它能在哪些平台跑」由 supports 自動抽取表達。而且實查 DEV 12 筆資料,值有兩種形態:空字串 '' 與大寫的 Windows,髒值已經存在。一個沒有約束、已經有髒值、語意含糊的自由文字欄位撐不起分類。保留在 _deprecated 表裡是為了觀察期內若發現有人在用還撈得回來。
被排除:保留並清洗——清洗 12 筆很容易,但清洗完它仍然與新三軸功能重疊,只是多一個要同步維護的欄位。 被排除:在新表建空欄位先佔位——那會變成第二個沒人維護的髒欄位。
決策內容:改名是主檔一列 UPDATE,所有版本天然共用同一個名稱;不升版。
定案理由:名稱是「這支基準叫什麼」,不是「這一版的內容」。若歷史版本保留舊名,列表上會出現兩個看起來不同的基準其實是同一支,比不改更混亂。這個行為是拆表的自然結果,不需要額外邏輯。
被排除:改名建新版——版號應該反映內容變更。改個錯字就升一版,會讓版號完全失去意義,也讓「有新版了」的提示變成噪音。
PUT /detection-tool-profiles/<uid>決策內容:承 D7,既然改名/改描述/改分類都不升版,就需要一個純粹的「編輯基本資料」端點。新增 PUT /detection-tool-profiles/<uid>,消費那顆已 seed 但至今沒人用的 detection-profile.update capability(FR-059 spec §12 坑 2 稱它為「預留孤兒」)。
權限規則:租戶只能編自己的(scope = TENANT 且 tenant_id 相符);公版(scope = SYSTEM)只有 root 能編。此判定走 common/authz/ 的資源域守門(app service 層),不要另立 helper。
定案理由:現況完全沒有「編輯基本資料」的入口——名稱只能在 fork/copy 時順便改,等於「改名要先複製一份」,這正是需求 ② 的直接來源。附帶效益:那顆 capability 從孤兒變成有實際消費者,權限矩陣不再有一列是死的。
被排除:沿用建立端點做 upsert——建立與編輯的權限判準不同(建立看 scope 能不能選、編輯看資源歸屬),混在一起會讓授權判定難以推理。
決策內容:只複製當前版,成為新基準的 v1(與現行為一致);控制項一併複製(不重新抽取),extraction_status 一併設為 succeeded。fork 與 copy_to_tool 抽成共用的 _clone_profile(),差異收斂成三個參數(scope/tenant_id/tool_id)。
定案理由:fork 的意圖是「我要以這份為起點做我自己的」,歷史版本對新的那支沒有意義(那是原基準的演進史,不是新基準的),且複製全部版本會讓儲存量與 file_id 引用倍增。控制項內容完全相同,重抽要再等 28~117 秒沒有意義。
被排除:複製全部版本鏈——儲存量與引用倍增,換來的是對新基準無意義的歷史。 被排除:fork 後重新抽取控制項——內容完全相同,白等一輪抽取。
extraction_status 落點決策內容:extraction_status 四態 pending/running/succeeded/failed,放從檔;再配一個 extraction_error 文字欄存失敗訊息。前端在列表與版本子表顯示狀態徽章,pending/running 時輪詢(建議 5 秒一次)。
定案理由:實測 708 條的 profile 抽取要 117 秒,遠超任何 HTTP 逾時,非同步是必然不是選項。狀態放從檔,是因為抽取是對某一版做的,不是對整支基準。
被排除:extraction_status 放主檔——主檔若只有一個狀態欄,上傳 v3 失敗會讓 v1、v2 的狀態一起被覆蓋成 failed,但那兩版的控制項其實好好的。 被排除:走 WebSocket 推播——為一個低頻操作接推播不划算,且專案的 SocketIO 走另一個 port。
決策內容:exit != 0 或 controls == [],兩者任一都算失敗。
定案理由:實測第七種錯誤形狀——.rb 控制項檔有 Ruby 語法錯誤時,cinc-auditor json 會 exit 0、stderr 全空、輸出合法 JSON、但 controls 是空陣列,整個檔案的控制項(包含語法正常的那些)全部被靜默丟棄。若只看 exit code,使用者會拿到一支「抽取成功但零控制項」的基準,他會以為是這份基準本來就沒東西,而不是解析壞了。靜默的錯誤比明顯的錯誤更貴。
誤判風險評估:理論上存在「合法但零控制項」的 profile(純 wrapper profile 只有 depends 沒有自己的控制項),但那種 profile 在本系統的使用情境下也是無效的(掃了什麼都不會驗)。把它判成失敗是正確的,錯誤訊息寫「未解析到任何控制項,請確認 profile 內容」即可。
被排除:只看 exit code——會讓第七種錯誤形狀變成無人察覺的資料缺漏。
決策內容:控制項一條一列存入 detection_profile_controls,欄位以「跨格式共通的最小集合」為正規欄,格式專屬的中繼資料整包進 attributes jsonb(見 D19),丟掉 code、丟掉與 descriptions.default 重複的 desc。
完整欄位:
| 類別 | 欄位 |
|---|---|
| 正規欄 | control_id/title/description/severity_raw/severity_norm/is_pending/source_ref(剝掉絕對路徑前綴的相對路徑) |
| 標記欄 | extraction_format(哪個格式抽出來的)+ origin(extracted | custom,FR-061 自訂項用;兩者正交,不可合併) |
| 其餘 | 全進 attributes jsonb(InSpec 放 tags + descriptions;XCCDF 未來放 ident/fixtext/rationale),為常用 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_id/role/category/gpo_path/gcb_value/check_type 各 234/234、status 199/234;TWGCB-01-011 則多出一個 service_display_name(368/708)。客戶自帶的外來 profile 更不可能有 TWGCB 那套 key,schema 本來就不可寫死成固定欄位。
期望值兩條路都拿得到(tags.gcb_value 與 descriptions.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 對帳的信任根)不是同一個值,切勿混用。
決策內容:BE 的 menu API 維持扁平陣列 [{value, name, target_type, target_product, benchmark_family, ...}],分組由 FE 自己做。
定案理由:① 相容性——JobExecutionDrawer.vue:279-282 的 for (const m of items) 若拿到巢狀會靜默失效(不 throw、不進 catch),任務抽屜的參數顯示直接退回裸的 profile:<uuid>,使用者看到一串 UUID;② 彈性——FE 想改用哪一軸分組(依標的類型或依基準體系)不必動 BE;③ 符合 CLAUDE.md 的 Menu pattern(回傳 List[MenuDto] 不包 PageDataDto)。
PrimeVue 3.53 的分組能力已實查確認:Dropdown 支援 optionGroupLabel/optionGroupChildren(Dropdown.d.ts:289,293;Dropdown.vue:285-293,881-891),且 editable 與分組可以並存(editable 只切換輸入框呈現、visibleOptions 的計算完全不看 editable、isValidOption 已排除 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 項本身也要包成一個群組,否則會混在群組之間造成渲染錯亂。
決策內容:主列表一列代表一支基準(主檔),預設只顯示 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/ 零命中,那份檔案是死的。
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 怎麼移轉」。
被排除:第一期就做版本級停用——沒有需求證據,且語意設計成本遠高於欄位成本。
決策內容:本案的 migration 只做 ALTER TABLE ... RENAME TO detection_tool_profiles_deprecated_20260803,不 DROP。DROP 另開一支 migration,於觀察期(建議一個 release 週期)後執行。
定案理由:專案慣例——「重構搬遷」與「刪舊表」是兩支獨立的 migration,中間隔一段觀察期。前例:2026-06-03-log-tables-partitioning.sql:66、2026-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 舊表——一旦搬遷有誤,資料無法回頭;且違反專案慣例。
決策內容:migration 內做部分補登——名稱裡含 twgcb-01-* 明確是作業系統、twgcb-02-003-google-chrome 明確是瀏覽器,這類從名稱可高信心推導的直接填;推不出來的留 NULL,由使用者在管理頁補。下拉分組時「未分類」放最後(分類明確是常態、未分類是待補的例外),列表篩選要有「未分類」選項讓使用者找得到待補的。
定案理由:13 筆裡有 10 筆是我們自己 seed 的 TWGCB 公版,我們比使用者更清楚它們是什麼。
被排除:全部留 NULL 等人工補——把已知資訊丟掉,等於讓使用者做我們已經知道答案的工。 被排除:全部靠猜測規則自動填——猜錯比留白更糟(使用者會信任錯誤的分類去篩選,篩掉他該看的東西)。寧可空著也不要猜。
決策內容:補做 root 建立公版的 UI,消費 FR-059 預留但未使用的 field_scope/hint_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 繼續懸空無人知曉。
決策者原話(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浮點/tags/descriptions/is_pending由impact==0.0推導)是 InSpec 專有語意。XCCDF 的對應概念完全不同:severity是五級列舉不是浮點、沒有「人工待判」概念(另有role=unchecked)、中繼資料是ident(CCE/CCI)/fixtext/rationale。第二個工具入庫時只剩兩條路:加一個severity欄,或把五級硬塞進浮點——兩條都是上線後改表,正是決策者指的「大幅度更新、上線後很危險」。
定案:三層擴充結構,擴充點一律是「加資料/加實作類別」,不是「加欄位/改欄位語意」
① 工具端宣告能力——沿用既有慣例,不發明新機制。 專案已有這個 pattern(detection_tool_param_schemas 的 options_source: profile_library 就是「這個工具的設定檔走庫」的宣告)。把宣告補完整:加 profile_format(inspec/xccdf/…)+ supports_extraction(能不能抽內容)。新工具入庫 = 加一列宣告,不動任何表。
② 抽取器走註冊表——依 profile_format 取對應 extractor。 管線不寫成「呼叫 cinc-auditor」,寫成「依格式取 extractor」。新格式 = 新增一個 extractor 類別 + 註冊一行,不動管線骨架、不動既有工具路徑。附帶效益:BE 裝 CINC 這項主機依賴只綁 inspec 格式,不會變成全平台前提。
③ controls 表存正規化語意 + 原始中繼資料分離。 正規欄只收所有格式一定都有的最小集合(control_id/title/description/severity_raw/severity_norm/is_pending/source_ref);格式差異全進 attributes jsonb;extraction_format 標記來源格式讓讀取端知道該期待什麼;origin(extracted | custom)與它正交,供 FR-061 自訂項使用。
代價要講清楚:現在只有 InSpec 一種真實格式,正規化是「照一個實例抽象」,有抽錯風險。降低風險的做法是正規欄只做最小集合——不要為了預想中的 XCCDF 去猜欄位。寧可多留在 attributes 裡,日後有第二個實例驗證後再提升為正規欄(那是加欄位,不是改語意,成本可接受)。
被排除:明寫「profile 庫只服務 InSpec 系」——太樂觀。決策者已明確表示未來其他工具的設定檔也會在這裡維護,把邊界劃死等於把問題推給下一次改表。 被排除:現在就完整抽象化(正規欄涵蓋預想中的 XCCDF 欄位)——只有一種真實格式時抽象化容易抽錯,沒有第二個實例可以驗證抽象是否正確。抽錯的抽象比沒有抽象更貴,因為它會誤導後續實作照錯的形狀寫。
對其他決策項的影響:D12 欄位定義已依此改寫(不用 impact 當欄位名);.1 建表採上述形狀;.3 抽取管線改註冊表骨架(工作量差異不大,都是新寫);D5 的「基準體系」軸不受影響——CIS/STIG 本來就是跨格式的體系名。
背景:.3 抽取管線(CM-1065)上線後,url 型基準(DEV 有兩支 dev-sec 公版)一律落 failed,訊息是「平台不代為下載」。使用者在下拉裡選得到它們,卻完全看不到內容——這正是本案 §1 原始需求要解的同一件事(「使用者完全不知道這個東西會驗證什麼」)。
推翻原假設的實測:協調者 2026-08-03 本機實測 cinc-auditor json <dev-sec tarball 網址> → exit=0、59 條控制項、profile name linux-baseline。CINC 原生就吃 URL。原本那道限制不是技術限制,是決策範圍問題。
P5 的原始範圍:FR-059 design P5「平台完全不經手檔案」的理由是 CM-992 限制②——「params.profile 是 secret: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:// 三種繞過全部通過。
三條不變式(做錯任一條都會製造更難查的問題):
cinc-auditor exec;_load_profile()/build_payload() 零異動(已逐欄比對 git HEAD 證明 payload 相同)upload_files、source_type 維持 url、file_id/sha256 維持 NULLsession_scope() 之外——_prepare() 對 url 型只回「待下載的網址」,實際下載由 worker 在兩段 session 之間做,維持既有三段式被排除:把下載內容存進 upload_files 變成 file 型快照——那會讓 url 型悄悄變成 file 型,破壞 P5 的雙軌語意;更糟的是掃描(agent 自己拉 URL)與抽取(平台存的快照)從此可能是不同的內容,對不上時沒人知道該信哪個。 被排除:網域白名單——決策者裁示不做(新增來源就要改設定),改以上述五道防護收斂風險。
FE 對應:url 型指向的是 master branch tarball,內容會漂,且沒有 sha256 當信任根。所以 url 型的控制項清單不可以跟 file 型長得一模一樣(否則使用者會拿它當稽核依據),成功態加一條「內容取自外部網址的當下快照,實際掃描時以檢測代理取得的版本為準」+顯示解析時間;file 型不顯示。
未來反悔條件:若出現私有 repo(需要憑證)的需求 → 回到 P5 的憑證代管議題,另案討論,不在本決策範圍內延伸。
以下四項一旦上線就改不了(或改的成本高到不會有人願意付),是本案最需要現在想對的地方:
| # | 決策 | 為什麼事後改不了 |
|---|---|---|
| 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_format/origin 雙標記) |
這是全案最不可逆的決定。上線後要接第二個格式,只能加資料,不能改欄位語意——已經落庫的幾萬列控制項會照舊語意被讀。若第一版就刻 InSpec 專有欄位(impact 浮點),XCCDF 進來時只剩「上線後改表」一條路 |
| D2 | 當前版指標用 is_current 布林 + partial unique index,不用 FK 當唯一性保證 |
唯一性保證換寫法要重建索引與改寫所有升版路徑。oscal.frameworks 走過 FK 那條路、官方已認錯廢棄,補救動作至今仍列在待 DROP 清單裡——那就是「事後改」的實際代價 |
| D3 | 從檔自己掛 RLS(冗餘 tenant_id/scope/org_unit_id) |
若第一版漏掛,租戶隔離從上線那刻起就是破的,且 file_id 洩漏搭配 FR-058.7 取檔通道是實質的檔案越權路徑。事後補掛要先確認沒有既存的越權存取、還要回頭補冗餘欄位的資料。本專案首例主從雙表 RLS,沒有前例可抄,必須一次做對 |
拆表不只是 DDL,會牽動既有的 repository、service 與派工鏈。以下是實際 grep 過程式碼後的盤點,不是推估。
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 防遞迴。
該避的坑:
_enrich(每列主檔各打一次 list_versions)main_version_id 外鍵的失敗史已寫在 D2,不重複。
| # | 現況問題 | 拆表後 |
|---|---|---|
| 1 | 唯一索引是 (detection_tool_id, tenant_id, name)——拿 name 當身分,這是改名改不動的根因 |
收斂成 (profile_id) WHERE is_current。改名變成主檔一列 UPDATE,版本鏈完全不受影響 |
| 2 | deactivate_profile 現在要同時降 is_active 和 is_current(後者純粹是為了不擋同名新版撞索引) |
那行整個消失——is_current 不再與 name 唯一性糾纏 |
| 3 | fork 與 copy_to_tool 是兩套幾乎重複的邏輯 |
差異收斂成三個參數(scope / tenant_id / tool_id),版本複製邏輯相同 → 抽共用 _clone_profile()(D9) |
原本以為拆表只影響管理面。實際 grep app/detection_tools/service/detection_orchestration_service.py 後發現兩處會壞,其中一處是無聲的授權漏洞。
🔴 破口 ① —
_load_profile()檢查profile.is_active
is_active拆表後在主檔。若_load_profile()拿到的是從檔 entity,有兩種壞法:
從檔 entity 的狀態 後果 嚴重度 沒有 is_active屬性AttributeError→ 500高,但會被發現 **有但預設 NoneNone 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。
這兩處已實際確認拆表後原樣可用,實作時不要順手改:
| 位置 | 為什麼不受影響 |
|---|---|
common/util/detection_profile_ref.py 的 build_payload() |
全部讀從檔欄位(source_type / file_id / url / sha256 / version),拆表後這些欄位仍在從檔,原樣可用 |
agent_file_access_service._profile_ref_resolver |
它比對的是 upload_files 的 uid,根本不碰 profile 表。FR-058.7 取檔通道的授權判定與本案拆表正交 |
依 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 列的 uid、file 型是 upload_files 的 uid。搬遷驗證查詢比對 uid 時必須分開對待,不要把兩者混在同一個 NOT EXISTS 裡比。
欄位歸屬照 D1(判準:「換一版會不會變」)。以下為邏輯欄位清單,實際 DDL 於 .1 的 migration 撰寫時定稿。
config.detection_profiles| 類別 | 欄位 | 說明 |
|---|---|---|
| 身分 | id/uid |
uid 另生新值(舊 uid 歸從檔,見 D4) |
| 基本資料 | name/description |
改名只動這一列,不升版(D7) |
| 分類三軸 | target_type/target_product/benchmark_family |
型別軸與體系軸存 enum key(D5),產品軸為自由文字 |
| 歸屬 | scope/tenant_id/org_unit_id |
RLS 判定來源 |
| 關聯 | detection_tool_id |
「這支基準給哪個工具用」是基準本身的屬性 |
| 狀態 | is_active/current_version_id |
current_version_id 是查詢便利欄,唯一性保證不靠它(D2) |
| 審計 | created_at/created_user/updated_at/updated_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| 類別 | 欄位 | 說明 |
|---|---|---|
| 身分 | id/uid |
🔴 uid 逐列沿用舊表(凍結契約,D4) |
| 關聯 | profile_id |
FK → 主檔,ON DELETE CASCADE |
| 來源 | source_type/file_id/url/sha256 |
sha256 是壓縮檔原始 bytes 的雜湊(FR-039 對帳信任根),不是 CINC 自算的 profile 雜湊 |
| 版本 | version/is_current |
配 UNIQUE (profile_id) WHERE is_current partial unique index(D2) |
| 內容衍生 | supports |
從該版 inspec.yml 抽取,唯讀不可編 |
| 抽取 | extraction_status/extraction_error/control_count/pending_count |
四態 pending/running/succeeded/failed(D10);兩個計數是統計索引,避免列表每次 COUNT(*) |
| RLS 冗餘 | scope/tenant_id/org_unit_id |
🔴 必須有(D3),policy 與主檔逐字對稱 |
| 審計 | 同主檔 |
config.detection_profile_controls欄位形狀照 D12 + D19:正規欄只收跨格式共通的最小集合,格式差異全進 jsonb。
| 類別 | 欄位 | 說明 |
|---|---|---|
| 身分 | id/uid |
|
| 關聯 | version_id |
FK → 從檔,ON DELETE CASCADE |
| 正規欄 | control_id |
控制項編號(如 twgcb_01_014_0079)。跨版本識別靠它,但不保證穩定(FR-061 定案) |
| 正規欄 | title/description |
清單主要顯示欄;description 取 descriptions.default,丟掉與它重複的 desc |
| 正規欄 | severity_raw/severity_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 |
哪個格式抽出來的(inspec/xccdf/…) |
| 標記欄 | origin |
extracted | custom(FR-061 自訂項用)。🔴 與 extraction_format 正交,兩者不可合併成一個欄位 |
| 其餘 | attributes jsonb |
InSpec 放 tags + descriptions;XCCDF 未來放 ident/fixtext/rationale。為常用 key 建 GIN 索引(至少 category,供列表 rowGroup 分組用) |
attributes 整包存 jsonb 的實測依據:tag key 集合隨 profile 變動——TWGCB-01-014 的 twgcb_id/role/category/gpo_path/gcb_value/check_type 各 234/234、status 199/234;TWGCB-01-011 多出一個 service_display_name(368/708)。客戶自帶的外來 profile 更不可能有 TWGCB 那套 key,schema 本來就不可寫死成固定欄位。
期望值兩條路都拿得到(tags.gcb_value 與 descriptions.gcb_value,本 profile 兩者一致),以 tags 為主、descriptions 為 fallback。
決策者裁示「DB 只存 key、由前端翻譯」,因此這張表要盡可能薄——不存顯示名稱、不開翻譯表。
表名建議 config.detection_profile_taxonomies(一張表容納兩個受控軸,用 axis 欄位區分,避免為兩個軸各開一張幾乎相同的表)。
| 欄位 | 型別 | 說明 |
|---|---|---|
id/uid |
||
axis |
text | 哪一軸:target_type | benchmark_family。標的產品是自由文字,不進這張表 |
key |
text | 純英數 slug,如 os/browser/database/twgcb/cis/stig。這是唯一會被 profile 引用的值 |
sort_order |
int | 下拉與分組的顯示順序(分組時「未分類」放最後,D17) |
is_active |
bool | 停用後不出現在新建/編輯選項,但既有引用仍解得出 key(見下方刪除保護) |
| 審計欄位 | 同其他表 |
唯一索引 (axis, key)。這張表是全域的(不分租戶)——分類骨幹由 root 統一維護,租戶不得增刪,否則篩選語意會漂移。因此不掛 RLS,比照專案內其他全域字典表的做法。
已被 profile 引用的分類值不可刪。刪掉之後既有 profile 的 target_type 會指向一個不存在的 key,列表篩選與下拉分組都會出現無法解釋的空白,且前端 fallback 只能顯示原始 key。
實作上兩層:
COUNT(*) 查引用數,非 0 直接擋下並回明確錯誤(引用數要一起回給前端,讓 root 知道擋在哪)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_scope/hint_scope_system 兩個 i18n key 卻沒做 UI,後來沒人知道那兩個 key 為什麼在那裡。不要製造第二個懸空孤兒。
現行 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_controls、oscal.poam_items、oscal.assessment_findings、oscal.ar_results、oscal.ssp_inventory_items、survey.task_survey_ref_items、survey.question_answer_history_details——九張明細表,零張掛 RLS。
其中 oscal.catalog_controls 與本案的控制項表是同一種東西(控制項明細),它就是不掛。本案跟進既有形狀,不開新做法。
判準寫成規則(未來新增子表時據以判斷):
本專案 RLS 掛在「有租戶身分、且可能被直接查詢」的主體表;純附屬明細(必須經主體帶出)不掛,也不加冗餘
tenant_id。
🔴 從檔一定要自己掛 RLS
PostgreSQL 的 RLS 不沿外鍵繼承。 只掛主檔的話,
SELECT * FROM 從檔完全不受限制 → 租戶 A 能列出租戶 B 的全部sha256/file_id/url。
file_id洩漏搭配 FR-058.7 取檔通道,就是一條實質的檔案越權路徑。而且從檔會被直接查——派工主幹道
_load_profile()走的就是從檔的get_by_uid,不是先查主檔再 join。
⚠️ helper 型別 cast 陷阱:八處漏一處就硬失敗
app_tenant_allowed_for_session(integer)只接 integer,而tenant_id是 bigint → policy 內必須寫tenant_id::integer。四段 policy × 兩張表 = 共 8 處,漏任何一處都會在套 migration 當下硬失敗。這個失敗是明顯的(不是靜默),但會讓整支 migration 回滾重來,撰寫時逐處對過。
⚠️ 本專案首例:主從兩表都帶 RLS
目前沒有任何「主從兩表都帶 RLS」的前例,FR-060 是首例,沒有既有實作可抄。因此 migration 內必須附上跨租戶讀取的驗證查詢——以
cm_app身分(受 RLS)分別模擬租戶 A 與租戶 B,確認:
- 租戶 A 讀不到租戶 B 的從檔列(這是最關鍵的一條,主檔擋得住不代表從檔擋得住)
- 兩者都讀得到
scope = 'SYSTEM'的公版- 租戶 A 無法 INSERT/UPDATE 出
scope = 'SYSTEM'的列
冗餘欄位的代價(D3 已述):改主檔的 scope/tenant_id 時要同步更新從檔。實務上這兩者建立後幾乎不變(fork 是建新列而非改欄位),代價很低;集中在 app service 層處理,不要散在各處。
全程在 psql --single-transaction -v ON_ERROR_STOP=1 內原子完成。十個步驟:
| # | 步驟 | 要點 |
|---|---|---|
| ① | CREATE 主檔 detection_profiles |
current_version_id 欄位建好,FK constraint 先不加(循環相依) |
| ② | CREATE 從檔 detection_profile_versions |
profile_id FK 立即生效;冗餘 tenant_id/scope/org_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 | 見下 |
⑩ 的驗證查詢(缺一不可):
current_version_id IS NOT NULL = 12 筆(不是 13——id 33 那條是合法的 NULL,D15)NOT EXISTS 比對舊表全部 uid,結果必須是 0INSERT 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 才驗得出來。
為什麼不用 DEFERRABLE:INITIALLY 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)。
管線不寫成「呼叫 cinc-auditor」,寫成「依格式取 extractor」。三層結構:
① 工具端宣告能力 — 沿用既有慣例,不發明新機制。detection_tool_param_schemas 已有 options_source: profile_library 這個「這個工具的設定檔走庫」的宣告,把它補完整:
| 新增宣告欄 | 值域 | 用途 |
|---|---|---|
profile_format |
inspec/xccdf/… |
決定取哪個 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 裡,日後有第二個實例驗證後再提升為正規欄(那是加欄位,不是改語意,成本可接受)。
比照 LibreOffice 慣例(docs/claude/host-dependencies.md:85-89)三層解析:
CINC_AUDITOR_CMD🔴 不要硬編 /usr/bin/cinc-auditor——macOS 開發機路徑不同。
⚖️ 授權紅線
必須是 CINC Auditor(Apache 2.0)。絕不可換成官方 InSpec 6+ 商業 binary(需接受 Chef EULA 並取得 license key);
inspec-coregem 由 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)。
壓縮檔 bytes 落成臨時檔(tempfile.NamedTemporaryFile(suffix=info.ext),副檔名必須正確,CINC 靠副檔名判斷格式)後交給 extractor。全程零 extract() 呼叫——不解壓成目錄樹,與 FR-059 T-1.3「不落地解壓」驗收條件不衝突(該條件禁止的是解壓成目錄,那是 zip-slip 可能寫出惡意路徑的時刻)。
| 分支 | 判定 | 處理 |
|---|---|---|
| 正常 | exit == 0 且 controls 非空 |
落庫,succeeded |
| 明確失敗 | exit != 0,stderr 有明確訊息(六種實測形狀:無 inspec.yml/空目錄/路徑不存在/YAML 格式壞/檔案無副檔名等) |
failed,記錄 stderr 到 extraction_error |
| 🔴 靜默失敗 | exit == 0、stderr 全空、JSON 合法,但 controls == [] |
也必須判 failed,訊息「未解析到任何控制項,請確認 profile 內容」 |
🔴 第三種分支:只看 exit code 會漏
.rb控制項檔有 Ruby 語法錯誤時,cinc-auditor json會 exit 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 逾時設定,非同步沒有折衷空間。
| 方法 | 端點 | 變更 | 說明 |
|---|---|---|---|
| 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 = TENANT 且 tenant_id 相符);公版(scope = SYSTEM)只有 root 能編。此判定屬資源域守門(要先 resolve 資源才知道判誰),走 common/authz/ 在 app service 層執行,不要另立 helper。
🔴 menu API 不可改成巢狀
JobExecutionDrawer.vue:279-282的for (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 項本身也要包成一個群組,否則會混在群組之間造成渲染錯亂。
本案不實作選用範圍、豁免、人工判定與自訂項,但資料模型必須現在就留好掛載點——這些是不可逆的架構決定,事後再改要動 FR-061 已寫好的東西。
| FR-061 能力 | 對 FR-060 模型的要求 |
|---|---|
| 豁免(某條控制項對某單位不適用) | 豁免要能綁定單一控制項 → 控制項必須是一列可被外鍵參照的資料,不能是 jsonb 陣列裡的元素。這是 D12 選擇「一條一列拆表」的決定性理由 |
| 選用範圍(這個單位只驗這些條目) | 選用範圍綁主檔不綁版本——單位的適用範圍是跨版本有效的政策決定,不該因為上傳新版就消失。但範圍內的條目清單要能對應到具體控制項,因此需要「控制項在跨版本間可識別」的機制(control_id 字串在同一支基準的不同版之間通常穩定,但不保證;此點留給 FR-061 定案) |
| 人工判定(199 條待判項的處理結果) | 判定結果要能接上既有的證據鏈——判定記錄要能引用 evidence/SSP 程序書,形狀比照現有的證據關聯。控制項表要有穩定的主鍵讓判定記錄指過來 |
| 自訂項(單位自己加的檢查條目) | 控制項表要能容納「不是抽取來的」列 → 來源標記欄 origin = extracted | custom,且重新抽取時不可清空自訂項。🔴 注意這與 D19 的 extraction_format(哪個格式抽出來的)是兩個正交的標記,不可合併成一個欄位 |
🔴 抽取欄位必須一次抽完整
不是抽「目前要顯示的子集」,是抽「CINC 給的全部(扣掉
code與重複的desc)」。如果 FR-060 只抽
id/title/desc三欄,FR-061 做豁免時發現需要tags、做人工判定時發現需要descriptions.gcb_value,就得把所有 profile 全部重抽一次——每支 28~117 秒,而且要寫一支資料回填 migration。存下來不顯示,成本是幾百 KB;沒存要重抽,成本是一整輪重跑加一支 migration。
D19 讓這條約束更強:重抽的成本不只是時間——多工具之後,某些格式可能根本無法重抽(工具端 API 已改版、或原始檔已不在)。
attributes整包存下來是唯一保險。
FR-061 的三種能力在 InSpec / CINC 都有原生對應,不需要自己發明機制——這也回頭確認了 FR-060 該抽哪些欄位:
| 能力 | InSpec 原生機制 | 細節 |
|---|---|---|
| 選用範圍 | --controls / --tags |
執行時以參數限縮要跑的控制項。這是 tags 必須完整存下來的直接理由——沒有 tags 就無法用 tag 篩選 |
| 豁免 | --waiver-file |
欄位:control_id(必填)/justification(必填)/expiration_date/run。豁免要有理由與到期日是 InSpec 原生設計,FR-061 照做即可,不必自創欄位 |
| 自訂項 | wrapper profile | depends + include_controls + skip_control——用一支 wrapper profile 引用原基準並增刪條目。這條路徑意味著 FR-061 可能要產生 profile 而非只是傳參數,複雜度較高,是它單獨成案的理由之一 |
決策者裁示(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 欄name 字面值name 欄一律保留字面值不變——搜尋(DetectionProfileManageView.vue:506-513 的 IconField 搜尋)與排序都在 SQL 端吃這個欄位,改成純 key 會讓搜尋與排序失效與 D5 的區別(同一個問題在不同欄位有不同答案)
分類 enum 可以只存 key——值域有限、只被下拉選取與分組使用、沒有人會打字搜尋分類值。
名稱不行——它會被搜尋、會被排序,兩者都在 SQL 端執行。存成純 key 會讓搜尋框搜不到中文名稱、排序變成按 slug 字母序。
理由是存取方式不同,不是標準不一致。未來看到這兩處做法有別,是刻意的。
切線在管理面/執行面之間,不是在「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 系統裡,使用者以為自己合規但實際不是,是嚴重問題——這種風險等級的變更值得單獨立案、單獨驗收。
CINC_AUDITOR_CMD 環境變數)config.detection_tool_profiles 在 POC 的存在狀態與資料筆數,作為未來上版的基準兩組分開: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)。
搬遷正確性
RLS(本專案首例主從雙表,必須逐條驗)
派工鏈迴歸(兩處破口)
抽取管線(三種分支各驗一次)
分類與編輯
前端
開發期間所有 migration 只套 DEV。 STG(已服役、有 10 筆資料)與 POC(等同 production,對外 demo/客戶試玩)一律等決策者當次明確指示才可套。