FR-060 · Detection Profile Content Management — 需求討論稿 · 2026-08-03(v1 待審)
FR-059 把 profile 檔搬進了後台,但使用者在下拉選單裡看到的仍只有一個名字。他不知道選下去會驗證什麼、有幾條、哪些機器要驗、哪些其實根本不會自動檢查。本案要把 profile 的內容打開——抽取控制項清單、建立分類體系、開放基本資料編修(含改名)。同時做一次資料模型重構:把現在的單表(名稱與版本混在一起)拆成「基準主檔 / 版本從檔 / 控制項」三層,改名才動得了、分類才掛得上、FR-061 的豁免與選用範圍才有得掛。
FR-059 解決了「profile 怎麼進系統」,本案解決「profile 進來之後使用者怎麼看懂它」。
目前只有項目,user 完全不知道這個東西會驗證什麼,我怎麼知道我要選什麼?哪些基準可以參考,我可以先請負責人去判斷、提前先處理,而不是等報告出來才去處理。 — 使用者原話(2026-08-03)
拿目前系統內最常被選的 TWGCB-01-014(Ubuntu 22.04 政府組態基準)實跑抽取,結果是:
234控制項總數
199人工待判項(impact 0.0,執行時直接 skip)
35真正會自動檢查的條數
15%自動化覆蓋率
使用者現在完全看不到這件事。他選了「TWGCB-01-014」,跑完拿到一份報告,才發現絕大多數條目是 skip——而 skip 的意思是「這條要人工判斷,工具幫不了你」。這正是原話裡「我可以先請負責人去判斷、提前先處理,而不是等報告出來才去處理」所指的缺口:199 條人工待判項如果在選擇當下就看得到,負責人可以提前展開作業;等報告出來才知道,等於白等一輪掃描。
打開一支基準,看得到它的控制項清單:編號、標題、說明、期望值、分類標籤、是否為人工待判項。
使用者要能在選擇之前判斷「這份基準適不適合我」「我要先請誰去準備什麼」。
租戶可以改自己上傳的 profile 名稱與描述;公版(scope = SYSTEM)只有 root 能改。
現況是改不動的——名稱參與唯一索引,改名等於改身分。目前唯一的「改名」途徑是複製一份新的(fork 時順便改名)。
決策者指出兩件事:CINC 連瀏覽器都有(不只作業系統,還有 Chrome、資料庫、容器等),以及 profile 一多,下拉會很長、會選錯。
需要一套分類軸讓下拉可以分組、列表可以篩選。
選用範圍、豁免、人工判定、自訂項——這些是對基準內容動手的能力,放 FR-061。
本案只寫「資料模型必須支撐什麼」(見 §4),不做實作。原因:這些能力直接改變掃描參數,改錯會讓結果不正確,必須等瀏覽與分類上線、看到實際使用情形再定形狀。
三個需求裡有兩個被現行資料模型直接卡住:
(detection_tool_id, tenant_id, name),等於拿名稱當身分。改名會撞索引、會讓版本鏈斷開。所以本案的第一步是主從拆表:detection_profiles(基準主檔:名稱/描述/分類/範圍)+ detection_profile_versions(版本從檔:來源檔/sha256/版號)+ detection_profile_controls(控制項)。詳細影響面見 §2B、§2C。
以下四組發現全部經過實際執行、實際查表驗證,不是推估。凡屬推測之處都會明白標示。這是後面所有決策項的事實基礎。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
subgraph NOW["現況(FR-059 上線形態)"]
A["config.detection_tool_profiles<br/>單表 · DEV 13 筆 · 全部 version=1<br/>唯一索引 (tool_id, tenant_id, name)<br/>名稱=身分 → 改名改不動<br/>分類無處掛 · 控制項無處放"]
end
subgraph NEXT["拆表後(FR-060.1)"]
B["detection_profiles 主檔<br/>name / description<br/>標的類型 / 標的產品 / 基準體系<br/>scope / tenant_id / org_unit_id<br/>is_active · current_version_id"]
C["detection_profile_versions 從檔<br/>uid 沿用舊表(凍結契約)<br/>source_type / file_id / url / sha256<br/>version / is_current / supports<br/>extraction_status<br/>冗餘 tenant_id / scope(RLS 用)"]
D["detection_profile_controls 控制項<br/>control_id / title / desc<br/>descriptions jsonb / tags jsonb<br/>impact / is_pending / source_ref<br/>(丟 code · 丟重複 desc)"]
end
A -->|"三段式搬遷<br/>13 筆 uid 逐列沿用"| B
B -->|"1 : N<br/>ON DELETE CASCADE"| C
C -->|"1 : N(234~708 條)<br/>ON DELETE CASCADE"| D
cinc-auditor json 實跑結果執行環境:DEV agent 主機 192.168.50.123,CINC Auditor 7.1.7
頂層 17 個 key(controls / groups / name / title / supports / sha256 / status …)。每一條 control 有 9 個欄位,234 條全數覆蓋、無缺漏:
| 欄位 | 型別 | 內容與用途 |
|---|---|---|
id |
string | 控制項編號,如 twgcb_01_014_0079 |
title |
string | 控制項標題(清單主要顯示欄) |
desc |
string | 主描述。與 descriptions.default 完全重複 |
descriptions |
object | 具名描述集合。desc 'gcb_value', '12個字元以上' 會進 descriptions.gcb_value |
impact |
float | 人工待判項的判準(見下方紅框) |
tags |
object | 扁平物件、值皆為 string。key 集合隨 profile 變動 |
refs |
array | 外部參照 |
code |
string | 整段 Ruby 原始碼。佔輸出體積 47.8% |
source_location |
object | 檔案位置。ref 是執行當下的絕對路徑 |
期望值兩條路都拿得到:tags.gcb_value 與 descriptions.gcb_value。本 profile 兩者值一致,建議以 tags 為主、descriptions 為 fallback。
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)。
結論
tag key 集合會隨 profile 變動 → schema 不可寫死成固定欄位,整包存 jsonb。客戶自帶的外來 profile 更不可能有 TWGCB 那套 key。
| 判準 | 命中條數 | 結果 |
|---|---|---|
impact == 0.0 |
199 | 正確 InSpec 原生語意,任何 profile 都有 |
code 含 skip |
199 | 正確 與上者同一集合 |
tags.status == 'pending-mapping' |
199 | 正確 但屬 TWGCB 自訂 tag,外來 profile 沒有 |
tags.check_type == 'pending' |
193 | 錯誤 · 漏 6 條 |
🔴 陷阱:用 check_type 判會漏 6 條
twgcb_01_014_0079 ~ 0084 這 6 條標的是 check_type: 'service',但實際上 impact 為 0.0 且程式碼只有一個 skip——它們是貨真價實的人工待判項,只是 tag 標錯了。
正解是 impact == 0.0——這是 InSpec 原生語意(impact 0 代表這條不做自動判定),客戶自帶的外來 profile 也一定有這個欄位,實測 100% 準確。不要依賴任何自訂 tag 做這個判定。
| 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 與重複的 desc 共省 59.6%。
🔴 117 秒遠超任何 HTTP 逾時設定
抽取必須非同步——上傳時做快速的結構驗證後立刻回應,抽取丟背景 job,前端輪詢或等通知。這一點沒有折衷空間,同步做法在 Windows 那支上必定逾時。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
autonumber
participant U as 使用者
participant FE as 前端
participant BE as BE API
participant JOB as 背景 job
participant CINC as cinc-auditor(BE 主機)
participant DB as PostgreSQL
U->>FE: 上傳 profile 壓縮檔
FE->>BE: POST 新版本
BE->>BE: 結構驗證(找 inspec.yml / 防 bomb / 防 slip)
Note over BE: 同步、毫秒級
BE->>DB: INSERT 版本列<br/>extraction_status = pending
BE-->>FE: 立即回應(不等抽取)
FE-->>U: 上傳成功 · 內容解析中
BE->>JOB: 排入抽取工作
JOB->>DB: extraction_status = running
JOB->>JOB: 壓縮檔落成臨時檔<br/>NamedTemporaryFile(suffix=.tar.gz)
JOB->>CINC: cinc-auditor json <臨時檔>
Note over CINC: 234 條 ≈ 28 秒<br/>708 條 ≈ 117 秒
alt exit == 0 且 controls 非空
CINC-->>JOB: JSON(17 個頂層 key)
JOB->>JOB: 解析:剝 source_location 路徑前綴<br/>丟 code · 丟重複 desc<br/>impact==0.0 標記人工待判
JOB->>DB: 批次 INSERT 控制項<br/>extraction_status = succeeded
else exit != 0(六種已知錯誤形狀)
CINC-->>JOB: stderr 有明確訊息
JOB->>DB: extraction_status = failed<br/>記錄錯誤訊息
else exit == 0 但 controls == []
CINC-->>JOB: 🔴 stderr 全空 · JSON 合法 · 零控制項
Note over JOB: Ruby 語法錯導致整檔靜默丟棄<br/>只看 exit code 會誤判成功
JOB->>DB: extraction_status = failed<br/>訊息:未解析到任何控制項
end
JOB->>JOB: 刪除臨時檔
六種會 exit 1 並在 stderr 給出明確訊息:無 inspec.yml/空目錄/路徑不存在/YAML 格式壞/檔案無副檔名等。
🔴 第七種:靜默失敗
.rb 控制項檔有 Ruby 語法錯誤時,cinc-auditor json 會 exit 0、stderr 全空、輸出合法 JSON、但 controls 是空陣列——整個檔案的控制項(包含語法正常的那些)全部被靜默丟棄。
對策:不能只看 exit code。controls == [] 也必須視為抽取失敗(見 D11)。若只看 exit code,使用者會拿到一支「抽取成功但零控制項」的基準,比抽取失敗更難察覺。
原本假設必須先解壓成目錄才能餵給 CINC。實測結果:cinc-auditor json 直接對 .tar.gz 與 .zip 執行即可——exit 0、234 條、輸出與讀目錄完全一致。
與 FR-059 T-1.3「不落地解壓」驗收條件的關係
不衝突。該條件禁止的是「解壓成目錄樹」——那是 zip-slip 可能寫出惡意路徑的時刻;它不禁止「把壓縮檔本身寫成一個臨時檔」。本案全程零 extract() 呼叫,只是把已驗證過的壓縮檔 bytes 落成臨時檔再交給 CINC。
限制:stdin 不支援、process substitution 不支援、副檔名必須正確(CINC 靠副檔名判斷格式)→ 實作用 tempfile.NamedTemporaryFile(suffix=info.ext)。
source_location.ref 是執行當下的絕對路徑(含臨時目錄名)→ 落庫前要剝掉前綴,只留 controls/xxx.rb。否則存進去的是無意義且會洩漏內部路徑的字串。sha256 是 CINC 自算的 profile 內容雜湊,與 detection_tool_profiles.sha256(壓縮檔原始 bytes 的雜湊,FR-039 對帳的信任根)不是同一個值——切勿混用。# omnitruck 官方安裝腳本,Linux / macOS 皆有包(macOS arm64 與 x86_64 dmg 都在)
curl -fsSL https://omnitruck.cinc.sh/install.sh | bash -s -- -P cinc-auditor -v 7安裝成本與紀律
體積 275MB,其中約 200MB 是本案用不到的 AWS / Azure / GCP SDK 與遠端 transport。
⚖️ 授權紅線:必須是 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 開發機路徑不同)。
三台部署機 + 每台開發機都要裝,收尾時要在 host-dependencies.md 新增第三項(現有兩項是中文字型與 LibreOffice)。
拆表不只是 DDL,會牽動既有的 repository、service 與派工鏈。以下是實際 grep 過程式碼後的盤點。
oscal.frameworks / oscal.framework_versions注意:這兩張表在 DEV 不存在,屬 jedi-oscal-v2 套件的表。只能參考程式碼模式,RLS 部分無實例可抄。
🔴 它的 main_version_id 外鍵已被官方認錯並事實廢棄
2026-06-16 改用 main_version 純字串欄,外鍵降級為冗餘、列入待 DROP(docs/features/FR-038-2606-oscal-redesign/handoff/2026-06-16-framework-maintenance-fixes-record.md:60)。造成三個傷害:
add_version 完全不碰它)對本案的意涵:應保留 FR-059 現行的 is_current 布林 + partial unique index。資料庫直接擋住兩筆 current;demote_current 先降後插的順序是被索引強制的,不是靠開發者自律。詳見 D2。
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)。
| # | 現況問題 | 拆表後 |
|---|---|---|
| 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() |
原以為拆表只影響管理面。實際 grep app/detection_tools/service/detection_orchestration_service.py 後發現兩處會壞:
🔴 破口 ① · _load_profile() 檢查 profile.is_active
拆表後 is_active 在主檔。若 _load_profile() 拿到的是從檔 entity:
AttributeError → 500None is False 為假 → 守門靜默失效,已停用的 profile 照樣派得出去後者是無聲的授權漏洞,比 500 更糟——不會有人發現。
🔴 破口 ② · _humanize_profile_param() 用 getattr(profile, "name", None)
name 拆表後在主檔。這裡有 default 值所以不會報錯,只會讓通知信裡的「掃描設定」靜默退化成 profile:<uuid>——而且目前沒有任何測試會抓到這個退化。
解法
domain service 新增 get_version_with_profile(uid),回傳主檔+從檔合成的 entity;orchestration 的三處改指它。對外行為零變化,且把「派工需要哪些欄位」這件事收斂到一個方法裡。
common/util/detection_profile_ref.py 的 build_payload()——全部讀從檔欄位,拆表後原樣可用agent_file_access_service._profile_ref_resolver——它比對的是 upload_files 的 uid,根本不碰 profile 表10 筆 SYSTEM(tenant 1)+ 3 筆 TENANT(tenant 102)。全部 version = 1,沒有任何真正的多版本鏈。
⚠️ id 32 / 33 不是版本鏈
這兩列同 tenant、同 name(twgcb-02-003-google-chrome)、同 file_id,但屬於不同工具(tool 8 vs tool 4)——是使用者選錯工具後停用重建留下的痕跡。
id 33 是唯一 is_current = f AND is_active = f 的列;拆表後它的主檔會變成「沒有任何 active version 的 profile」,UI 要能處理這個狀態。
🔴 搬遷的 JOIN 條件必須是 (tool_id, tenant_id, name) 三欄——少一欄,32 與 33 會 cross join,搬出錯誤的主從關係。
本表沒有任何外鍵(進出皆為 0)。detection_tool_id / file_id / tenant_id 全是 soft-ref。
| 位置 | 現況筆數 | 內容 |
|---|---|---|
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(agent 拿它去打 FR-058.7 取檔通道)。這兩個 uid 來自不同的表,搬遷時必須分開對待。
這也是 D4 建議「uid 由從檔沿用」的直接理由——只要從檔逐列沿用舊 uid,這 10 筆既有引用零轉換、零風險。
現行 config.detection_tool_profiles 的 RLS 結構:
| Policy | 邏輯 |
|---|---|
| SELECT | scope = 'SYSTEM' 人人可讀(此分支放最前),其餘走租戶隔離 |
| INSERT | 堵 scope <> 'SYSTEM'——防租戶偽造公版 |
| UPDATE (WITH CHECK) | 堵 scope <> 'SYSTEM'——防租戶把自己的 profile 竊升成公版 |
| DELETE | 堵 scope <> 'SYSTEM' |
⚠️ helper 型別 cast 陷阱
app_tenant_allowed_for_session(integer) 只接 integer,而 tenant_id 是 bigint → 必須寫 tenant_id::integer。四段 policy × 兩張表 = 共 8 處,漏任何一處都會硬失敗(不是靜默的,但會在套 migration 當下才爆)。
🔴 從檔一定要自己掛 RLS
PostgreSQL 的 RLS 不沿外鍵繼承。只掛主檔的話,SELECT * FROM 從檔 完全不受限制 → 租戶 A 能列出租戶 B 的全部 sha256 / file_id / url。file_id 洩漏搭配 FR-058.7 取檔通道,就是一條實質的檔案越權路徑。
而且從檔會被直接查——派工主幹道 _load_profile() 走的就是從檔的 get_by_uid,不是先查主檔再 join。
建議:從檔冗餘存 tenant_id / scope / org_unit_id,policy 與主檔逐字對稱。詳見 D3。
⚠️ 本專案目前沒有任何「主從兩表都帶 RLS」的前例,FR-060 是首例。設計時要格外小心,且建議在 migration 內附上跨租戶讀取的驗證查詢。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TD
S["psql --single-transaction<br/>-v ON_ERROR_STOP=1"] --> A1
A1["① CREATE 主檔 detection_profiles<br/>current_version_id 欄位建好<br/>但 FK constraint 先不加"] --> A2
A2["② CREATE 從檔 detection_profile_versions<br/>profile_id FK 立即生效<br/>冗餘 tenant_id / scope / org_unit_id"] --> A3
A3["③ CREATE 控制項表 detection_profile_controls<br/>version_id FK · ON DELETE CASCADE"] --> B1
B1["④ INSERT 主檔<br/>SELECT DISTINCT (tool_id, tenant_id, name)<br/>current_version_id 留 NULL"] --> B2
B2["⑤ INSERT ... SELECT 從檔<br/>🔴 JOIN 條件三欄缺一不可<br/>🔴 uid 逐列沿用舊表"] --> B3
B3["⑥ UPDATE 主檔回填 current_version_id"] --> C1
C1["⑦ ALTER TABLE 主檔<br/>ADD FK current_version_id → 從檔"] --> C2
C2["⑧ 建 RLS:四段 × 兩表 = 8 段<br/>tenant_id::integer cast 逐處確認"] --> D1
D1["⑨ RENAME 舊表<br/>detection_tool_profiles<br/>→ _deprecated_20260803<br/>(不 DROP · 觀察期後另開 migration)"] --> V
V{"⑩ 驗證查詢<br/>全部通過才 COMMIT"}
V --> V1["主檔 = 13 筆"]
V --> V2["從檔 = 13 筆"]
V --> V3["current_version_id IS NOT NULL = 12<br/>(id 33 那條是 NULL)"]
V --> V4["🔴 uid 零遺失<br/>NOT EXISTS 比對舊表 → 必須 0"]
V --> V5["INSERT public.schema_migrations"]
為什麼不用 DEFERRABLE
INITIALLY DEFERRED 會讓應用層每一次寫入都延到 commit 才檢查外鍵,錯誤發生點離現場很遠、極難除錯。而應用層「先 INSERT 從檔 → 再 UPDATE 主檔指標」的順序本來就自然,不需要 defer。遷移期間的循環相依用「先不加 FK constraint、最後 ALTER 補上」解決即可。
⚠️ 舊表 rename 保留、不 DROP
專案慣例:「重構搬遷」與「刪舊表」是兩支獨立的 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_*)避免與舊表殘留的索引名撞名。
🔴 這不是動 DEV 實驗表,是 rename 一張 STG 正在服役的表
實查結果:STG(188 的 guidant_ai_stg)與 DEV 同為 125 筆 migration,FR-059 全 5 支都在 STG。config.detection_tool_profiles 在 STG 存在且有 10 筆(全部 SYSTEM,無 TENANT),RLS 四段齊全。
意涵一:STG 全部 SYSTEM、沒有 TENANT 資料 → 搬遷 SQL 在 STG 測不到租戶路徑、測不到 32/33 的三欄 JOIN 陷阱、測不到停用列造成的孤兒主檔。「STG 跑得順」不能當作驗收依據。
意涵二:POC(189)本次未查(遵守環境紀律,唯讀查詢也不主動做)。上版前需另行唯讀確認 + 決策者放行。
🔴 開發期只套 DEV。STG / POC 一律等決策者明示放行(環境異動鐵律;FR-058 的 11 支 migration 全同步進三環境、其中 4 支 seed 讓 POC 顯示四個 available 但 agent 無對應 connector,是殷鑑)。
好消息:分組下拉、主從展開、分類枚舉化,專案內全部有現成樣板可抄。壞消息:有一個必然觸發的靜默 bug。
實查套件原始碼確認: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🔴 最容易漏的靜默 bug
DetectionConfigField.vue:82-92 有一段 opts.some(o => o.value === current)——用來判斷「目前選中的值是否還在選項裡」,不在就補一個「已不可用」的 fallback 項。
選項改成巢狀後,這個比對必然全部 miss(它比對到的是 group 物件,不是 leaf 選項)→ 每一個已選值都會多長一個「已不可用」的 fallback 項。這不會報錯、不會進 catch,只會讓每張已設定的任務都顯示成「這個 profile 已不可用」。
對策:比對前先攤平(flatten)選項樹;fallback 項本身也要包成一個群組,否則它會混在群組之間造成渲染錯亂。
menu API 契約建議:BE 保持扁平
建議 BE 的 menu API 維持扁平陣列 [{value, name, target_type, ...}],由 FE 自己 groupBy。
若 BE 改回傳巢狀,JobExecutionDrawer.vue:279-282 的 for (const m of items) 會靜默失效(不 throw、不進 catch)——任務抽屜的參數顯示會直接退回裸的 profile:<uuid>,使用者看到一串 UUID。詳見 D13。
DeviceManage.vue:36-57 面對的幾乎是同一個問題(自由文字欄位要枚舉化,同時相容既有資料),做法是三層 fallback:
te() 測試)→ 顯示翻譯unshift 保留在選項最前直接照抄那 28 行即可。
⚠️ 不要跟進 options.json
專案內另有一份 options.json 集中式 enum i18n 檔,但實查 grep "lang.options\." src/ 零命中——那份檔案是死的,沒有任何地方在用。不要因為看到它就跟進那套機制。
SheetPreviewControls.vue——expander + rowGroup subheader,是專案內唯一同時滿足兩種需求的實例。
ControlListEditor.vue——dataKey-keyed object 的單一展開模式,較輕量。
專案內無現成的大量清單元件,但 client-side DataTable + :paginator 25 筆/頁完全撐得住(PrimeVue 只渲染當前頁)。
建議組合:以 ControlListEditor.vue 為骨架 + SheetPreviewControls.vue 的 rowGroup + 複製 DetectionProfileManageView.vue:506-513 的 IconField 搜尋(已含 300ms debounce)。
不建議 server-side lazy loading
控制項是某一版的靜態內容——不會變、不需要即時性。載一次快取住,比每次翻頁多打 N 次 API 快得多、也簡單得多。
VirtualScroller 專案內零使用,除非實測超過 1MB 或 2 秒,否則不要為這個功能開一個新 pattern。
| 項目 | 現況 |
|---|---|
| 改動量 | DetectionProfileManageView.vue 共 1,239 行,其中約 600–700 行要重構。建議趁機拆檔(主表格/版本子表/各 Dialog 各自獨立元件),否則會膨脹到 2,000 行以上 |
| 缺口 · 無編輯入口 | 目前完全沒有「編輯基本資料」的入口——名稱只能在 fork / copy 時順便改,等於「改名要先複製一份」。這正是需求 ② 的直接來源 |
| 缺口 · 兩個懸空 i18n key | field_scope / hint_scope_system——當初為「root 建公版」預留但 UI 沒做,createDialog.scope 恆為 TENANT。詳見 D18 |
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TD
L["掃描設定檔管理(列表頁)<br/>一列 = 一支基準(主檔)<br/>欄:名稱 / 標的類型 / 標的產品 / 基準體系<br/> 所屬工具 / 範圍 / 當前版 / 控制項數<br/>篩選:分類三軸 + 工具 + 範圍<br/>開關:顯示已停用與舊版"]
L -->|"編輯"| E["基本資料編輯 Dialog<br/>名稱 / 描述 / 分類三軸<br/>租戶限自己的 · 公版限 root<br/>不升版(D7 / D8)"]
L -->|"展開列(expander)"| V["版本歷史子表<br/>v3 (current) / v2 / v1<br/>來源型態 / sha256 / 上傳者 / 時間<br/>抽取狀態徽章"]
L -->|"新增版本"| U["上傳新版<br/>→ 抽取管線(圖 2)"]
L -->|"fork / 複製到另一工具"| F["_clone_profile()<br/>只複製當前版為新 v1"]
V -->|"點某一版"| D["版本詳情頁"]
D --> D1["摘要區<br/>控制項總數 234<br/>🔴 人工待判 199<br/>自動檢查 35<br/>執行平台(supports 唯讀)"]
D --> D2["控制項清單<br/>DataTable + paginator 25/頁<br/>搜尋(IconField + 300ms debounce)<br/>rowGroup 依 tags.category<br/>expander 展開單條詳情"]
D2 --> D3["單條詳情<br/>編號 / 標題 / 說明<br/>期望值(tags.gcb_value)<br/>全部 tags<br/>人工待判標記"]
D3 -.->|"FR-061"| X["選用範圍 / 豁免<br/>人工判定 / 自訂項"]
已定案 2 項、建議預設 1 項、待決策 D1–D18,另有 D19(多工具擴充邊界)決策者已當場定調、列於 D18 之後。每一項都附我的建議、利弊、以及被排除的方案與理由——請決策者覆核或推翻,不需要從零思考。
✅ 抽取落點 = BE 主機安裝 CINC Auditor
抽取工作在 BE 執行,不派給 agent。成本已於 §2A 列明:275MB 體積(含 200MB 用不到的雲端 SDK)、授權紅線(必須 CINC Auditor Apache 2.0,不可用官方 InSpec 6+ 或 inspec-core gem)、三台部署機 + 每台開發機都要裝、收尾要在 host-dependencies.md 加第三項、路徑解析走 CINC_AUDITOR_CMD 環境變數比照 LibreOffice 慣例。
被排除:派 agent 抽取——BE 端零主機依賴是優點,但要新增一種 agent 指令型別、且依賴 agent 在線才能抽取(使用者上傳完卻因為 agent 離線而看不到內容,體驗不可接受)。
✅ 版本鎖定 = 鎖版 + 提示(決策者已表態傾向,仍列此供覆核)
任務綁定版本 uid(即現行行為)——FR-059 D2 契約零變動、10 筆既有綁定零轉換。
但純鎖版有合規漏洞:TWGCB 發了新版,排程任務會無聲地繼續掃舊版,使用者以為自己合規、實際上不是。在 GRC 系統裡這是正確性問題,不是 UX 瑕疵。
因此要配一個提示:「此基準已有新版 v3,目前使用 v1」徽章 + 一鍵換版。換不換是使用者的決定,但必須讓他知情。拆表後「是不是現行版」就是主檔 current_version_id 的一次比對,menu API 順手帶回,成本極低。BE 側留在 FR-060;FE 的徽章與一鍵換版若範圍太肥可移到 FR-061(BE 先把資料備好)。
被排除:跟隨現行版——同一張任務前後兩次執行會跑出不同結果、稽核無法回溯、破壞 FR-059 已凍結的契約,還要轉換 10 筆既有資料。代價全付了,換來的性質卻更差。
D1 · 主從欄位歸屬
哪些欄位屬「基準本身」(主檔)、哪些屬「某一版」(從檔)?這決定了改名、分類、版更各自會動到什麼。
我的建議:
主檔——name / description / 分類三軸(標的類型 / 標的產品 / 基準體系)/ scope / tenant_id / org_unit_id / detection_tool_id / is_active / current_version_id
從檔——source_type / file_id / url / sha256 / version / is_current / supports / extraction_status / 控制項統計索引(總數 / 待判數)
判準一句話:「換一版會不會變」——會變的進從檔,不會變的進主檔。supports(執行平台)放從檔是因為它是從該版的 inspec.yml 抽出來的,不同版可能不同。
被排除:分類放從檔——那等於每次上傳新版都要重填一次分類,且同一支基準的不同版可能分到不同類,篩選結果會前後矛盾。
D2 · 當前版指標:is_current 布林 vs current_version_id 外鍵
兩種寫法都能表達「哪一版是現行版」,但保證強度差很多。
我的建議:保留 FR-059 現行的 is_current 布林 + partial unique index(UNIQUE (profile_id) WHERE is_current)。資料庫直接擋住兩筆 current,demote_current 先降後插的順序是被索引強制的,不是靠開發者記得。
被排除:current_version_id 外鍵——這正是 oscal.frameworks 走過的路,而且官方已認錯並事實廢棄(2026-06-16 改用純字串欄,外鍵降級為冗餘、列入待 DROP)。三個具體傷害:① 循環外鍵逼得 DDL 拆兩段、Python model 退化成 plain int;② 刪除流程被綁死,不能先刪版本只能靠 cascade;③ 資料庫保證不了唯一性,而應用層從頭到尾沒實作過切換。
折衷可行:current_version_id 仍可以存在當作查詢便利欄(避免每次列表都要 join 從檔篩 is_current),但唯一性的保證來源是 partial unique index,不是這個外鍵。兩者不衝突。
D3 · 從檔 RLS 寫法:冗餘欄位 vs EXISTS 子查詢繼承
從檔一定要自己掛 RLS(PostgreSQL 不沿外鍵繼承,理由見 §2C)。問題是租戶判定的欄位從哪來。
我的建議:從檔冗餘存 tenant_id / scope / org_unit_id,policy 與主檔逐字對稱。
理由三條:① 本專案零 EXISTS 繼承前例——detection_executions、job_execution_detection_tools 等子表全部是冗餘欄位形狀,跟進既有形狀降低理解成本;② 效能——EXISTS 每一列都要跑一次子查詢;③ 子查詢本身還會再套一次主檔的 policy,形成 policy 套 policy,除錯時極難推理。
冗餘的代價:改主檔的 scope / tenant 時要同步更新從檔。但實務上 scope 與 tenant_id 建立後幾乎不變(fork 是建新列而非改欄位),代價很低。建議在 app service 層集中處理,不要散在各處。
⚠️ 提醒:本專案目前沒有任何「主從兩表都帶 RLS」的前例,FR-060 是首例,設計時請額外附上跨租戶讀取的驗證查詢。
D4 · uid 歸屬
拆表後主檔與從檔都需要 uid。舊表那 13 個 uid 該給誰?
我的建議:從檔逐列沿用舊 uid,主檔另生新 uid。
理由:舊 uid 是凍結契約——job_execution_detection_tools.tool_params 的 4 筆 profile:<uid>、agent_tasks.params 的 6 筆 _profile 都指著它。從檔沿用等於這 10 筆既有引用零轉換、零風險;而且語意也對——派工綁的本來就是「某一版」不是「某支基準」。
被排除:主檔沿用舊 uid——那 10 筆引用全部要改寫(改 JSONB 內容),而且語意變成「派工綁基準」,與已拍板的「鎖版」相牴觸。
D5 · 分類三軸的定義與 enum 來源
決策者指出兩個現象:CINC 的 profile 不只作業系統,連瀏覽器都有;profile 一多下拉會很長、會選錯。需要一套分類軸。
我的建議:三軸
① 標的類型(受控 enum):作業系統 / 瀏覽器 / 應用程式 / 資料庫 / 網通設備 / 雲端服務 / 容器 / 其他
② 標的產品(自由文字):Ubuntu 22.04、Google Chrome、PostgreSQL 15 …
③ 基準體系(受控 enum):TWGCB / CIS / STIG / dev-sec / 自訂
④ 執行平台——不是第四軸,從 inspec.yml 的 supports 自動抽取、唯讀不可編(它是事實不是分類)
為什麼標的產品用自由文字而非 enum:產品名稱長尾極長(客戶自帶的 profile 可能是任何東西),硬做 enum 會逼使用者選「其他」,反而失去篩選價值。以自由文字 + 前端 autocomplete 既有值的方式處理。
待決:enum 值來源——(a) FE 寫死常數,改一次要發版;(b) 存 DB(比照 DeviceManage.vue 用 system_menus 的做法),可由 root 維護。我傾向 (a) FE 寫死:這八個類型是穩定的分類骨幹、不該讓租戶隨意增刪(增刪會讓篩選語意漂移),要新增類型時發版一次是合理成本。若決策者認為需要客戶自訂,再走 (b)。
D6 · platform_hint 退役
現行有一個 platform_hint 欄位。實查 DEV 12 筆資料,值有兩種形態:空字串 '' 與大寫的 Windows——髒值已經存在。
我的建議:退役此欄位。它的功能被 D5 的三軸完全覆蓋而且更精確:「這支基準是給什麼用的」由標的類型+標的產品表達,「它能在哪些平台跑」由 supports 自動抽取表達。一個沒有約束、已經有髒值、語意含糊的自由文字欄位撐不起分類。
退役方式:拆表時不搬到新表,值隨舊表一起保留在 rename 後的 _deprecated 表裡(觀察期內若發現有人在用,還撈得回來)。不要在新表建一個空的 platform_hint 欄位——那會變成第二個沒人維護的髒欄位。
被排除:保留並清洗——清洗 12 筆很容易,但清洗完它仍然與新三軸功能重疊,只是多一個要同步維護的欄位。
D7 · 改名的語意
使用者改了名稱,歷史版本要跟著改,還是只改當前版?
我的建議:整鏈一起改,且不升版。拆表後改名就是主檔一列 UPDATE,所有版本天然共用同一個名稱——這個行為是拆表的自然結果,不需要額外邏輯。
理由:名稱是「這支基準叫什麼」,不是「這一版的內容」。若歷史版本保留舊名,列表上會出現兩個看起來不同的基準其實是同一支,比不改更混亂。
被排除:改名建新版——版號應該反映內容變更。改個錯字就升一版,會讓版號完全失去意義(也讓「有新版了」的提示變成噪音)。
D8 · 「只改描述」不再算版更 → 新增編輯端點
承 D7:既然改名 / 改描述 / 改分類都不升版,就需要一個純粹的「編輯基本資料」端點。
我的建議:新增 PUT /detection-tool-profiles/<uid>,消費那顆已 seed 但至今沒人用的 detection-profile.update capability(FR-059 spec §12 坑 2 稱它為「預留孤兒」)。
附帶效益:這顆 capability 從「孤兒」變成有實際消費者,權限矩陣不再有一列是死的。
權限規則:租戶只能編自己的(scope = TENANT 且 tenant_id 相符);公版(scope = SYSTEM)只有 root 能編。這個判定走 common/authz/ 的資源域守門(app service 層),不要另立 helper。
D9 · fork / copy_to_tool 的版本複製語意
fork 一支有 3 個版本的基準,新的那支要有幾個版本?
我的建議:只複製當前版,成為新基準的 v1(與現行為一致),並把 fork 與 copy_to_tool 抽成共用的 _clone_profile()。
理由:fork 的意圖是「我要以這份為起點做我自己的」,歷史版本對新的那支沒有意義(那是原基準的演進史,不是新基準的)。且複製全部版本會讓儲存量與 file_id 引用倍增。
拆表後的簡化:fork 與 copy_to_tool 的差異收斂成三個參數(scope / tenant_id / tool_id),版本複製邏輯完全相同 → 一個方法帶參數即可,不必兩套。
控制項要不要一起複製:建議複製(而非重新抽取)——內容完全相同、重抽要再等 28~117 秒沒有意義。但要注意 extraction_status 要一併設為 succeeded。
D10 · 抽取非同步化與 extraction_status 欄位落點
實測 708 條的 profile 抽取要 117 秒,遠超任何 HTTP 逾時 → 非同步是必然,不是選項。需要一個狀態欄讓前端知道進度。
我的建議:extraction_status 四態 pending / running / succeeded / failed,放從檔——抽取是對某一版做的,不是對整支基準。再配一個 extraction_error 文字欄存失敗訊息(給使用者看,也給支援人員除錯)。
為什麼不放主檔:主檔若只有一個狀態欄,上傳 v3 失敗會讓 v1、v2 的狀態一起被覆蓋成 failed,但那兩版的控制項其實好好的。
前端呈現:列表與版本子表顯示狀態徽章;pending / running 時輪詢(建議 5 秒一次,抽取本來就要幾十秒)。不建議走 WebSocket——為一個低頻操作接推播不划算,且專案的 SocketIO 走另一個 port。
D11 · 抽取失敗的判定條件
承 §2A 的第七種錯誤形狀:Ruby 語法錯會導致 exit 0 + stderr 全空 + JSON 合法 + controls: []。
我的建議:exit != 0 或 controls == [],兩者任一都算失敗。
理由:若只看 exit code,使用者會拿到一支「抽取成功但零控制項」的基準——他會以為是這份基準本來就沒東西,而不是解析壞了。靜默的錯誤比明顯的錯誤更貴。
誤判風險評估:真的存在「合法但零控制項」的 profile 嗎?理論上有(純 wrapper profile 只有 depends 沒有自己的控制項),但那種 profile 在本系統的使用情境下也是無效的(掃了什麼都不會驗)。把它判成失敗是正確的,錯誤訊息寫「未解析到任何控制項,請確認 profile 內容」即可。
D12 · 控制項的存儲形式
234~708 條控制項,存成一個大 jsonb,還是拆成一條一列?
我的建議:一條一列拆表(detection_profile_controls),欄位以「跨格式共通的最小集合」為正規欄,格式專屬的中繼資料整包進 attributes jsonb(詳見 D19),丟掉 code、丟掉與 descriptions.default 重複的 desc(省 59.6% 體積,實測)。
拆表的理由(決定性):FR-061 的豁免要能綁單一控制項——那需要控制項是一列可被外鍵參照的資料,不是 jsonb 陣列裡的一個元素。這是不可逆的架構決定,現在做對成本很低,之後改要動 FR-061 已寫好的東西。
其他理由:搜尋 / 篩選 / 分頁走 SQL 而非 Python;「人工待判 199 條」這種統計是一句 COUNT(*) WHERE is_pending;未來要跨 profile 找「哪些基準有驗這條」也有路。
🔴 欄位命名不可用 InSpec 專有名詞(D19 定調):嚴重度欄位不叫 impact——那是 InSpec 的 0.0–1.0 浮點語意,XCCDF 用的是五級列舉,其他工具各有各的。改成 severity_raw(原樣存來源值,文字)+ severity_norm(跨工具可比的統一級距)。is_pending 保留欄位名,但判準寫在各格式的 extractor 裡(InSpec 是 impact == 0.0,XCCDF 另有 role=unchecked),不是寫死在表結構的假設裡。
格式專屬中繼資料進 attributes jsonb:InSpec 放 tags + descriptions;XCCDF 放 ident(CCE/CCI)/fixtext/rationale;未來格式放它自己的。這一格就是「加資料不加欄位」的著力點——第二個工具入庫時不必 ALTER TABLE。實測理由同樣成立:tag key 集合隨 profile 變動(TWGCB-01-011 比 -014 多一個 service_display_name),外來 profile 更不可能有 TWGCB 那套 key → schema 本來就不可寫死。為常用 key 建 GIN 索引(至少 category)。
丟 code 的代價:使用者看不到「這條實際怎麼檢查」的 Ruby 原始碼。我認為可接受——目標受眾是合規負責人不是 InSpec 開發者,且 47.8% 的體積換一個幾乎沒人看的欄位不划算。若日後有需求,從 source_ref 可以定位到檔案,不是完全無路。
完整欄位建議:正規欄 control_id / title / description / severity_raw / severity_norm / is_pending / source_ref(剝掉絕對路徑前綴的相對路徑);標記欄 extraction_format(哪個格式抽出來的)+ origin(extracted | custom,FR-061 自訂項用,兩者正交);其餘全進 attributes jsonb。
D13 · menu API 的形狀:扁平 vs 巢狀
下拉要分組,分組資料由誰組?
我的建議:BE 保持扁平 [{value, name, target_type, target_product, benchmark_family, ...}],FE 自己 groupBy。
理由:① 相容性——JobExecutionDrawer.vue:279-282 的 for (const m of items) 若拿到巢狀會靜默失效(不 throw、不進 catch),任務抽屜的參數顯示直接退回裸的 profile:<uuid>;② 彈性——FE 想改用哪一軸分組(依標的類型 or 依基準體系)不必動 BE;③ 符合 CLAUDE.md 的 Menu pattern(回傳 List[MenuDto] 不包 PageDataDto)。
被排除:BE 回傳巢狀——省了 FE 十幾行 groupBy,換來一個靜默失效的相容性風險,不划算。
🔴 不論選哪個,D13 都必須配 §2D 的 fallback 修補:DetectionConfigField.vue:82-92 的 opts.some(o => o.value === current) 在選項變巢狀後必然全 miss,每個已選值都會多長一個「已不可用」項。要先攤平再比對,fallback 項自己也要包成群組。
D14 · 列表語意:主列表 vs 版本歷史
拆表後列表一列代表什麼?現有的「顯示已停用與舊版」開關語意會分裂成兩件事。
我的建議:主列表一列 = 一支基準(主檔),預設只顯示 is_active 的;版本歷史走展開列(expander,pattern 抄 SheetPreviewControls.vue)+ 另有獨立分頁 API(給版本多的情況)。
「顯示已停用與舊版」開關的分裂:拆表後這是兩個獨立概念——「已停用的基準」(主檔 is_active = false)與「非當前版」(從檔 is_current = false)。建議拆成兩個開關:主列表上是「顯示已停用基準」,版本子表上是「顯示歷史版本」(子表本來就該全顯,這個開關可能根本不需要)。
被排除:一列一版本(維持現行形狀)——那等於沒拆表,改名、分類、篩選全部回到原點。
D15 · is_active 只放主檔,還是從檔也要狀態欄
現況 id 33 是列級停用(is_active = f)。拆表後這個語意要放哪一層?
我的建議:第一期 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 週期)後執行。
前例: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 退役表前安全四查」,確認沒有任何 live-wired 的 code 還在引用(拆表後應該沒有,但要實際 grep 確認,不要假設)。
D17 · 既有 13 筆的分類補登策略
拆表後主檔多了三個分類欄位,13 筆既有資料要填什麼?
我的建議:migration 內做「有把握的才填,其餘留 NULL」——名稱裡含 twgcb-01-* 明確是作業系統、twgcb-02-003-google-chrome 明確是瀏覽器,這類從名稱可高信心推導的直接填;推不出來的留 NULL,由使用者在管理頁補。
被排除:全部留 NULL 等人工補——13 筆裡有 10 筆是我們自己 seed 的 TWGCB 公版,我們比使用者更清楚它們是什麼,讓使用者補等於把已知資訊丟掉。
被排除:全部靠猜測規則自動填——猜錯比留白更糟(使用者會信任錯誤的分類去篩選,篩掉他該看的東西)。寧可空著也不要猜。
「未分類」群組的位置:下拉分組時,未分類的項目放最後(不是最前)——分類明確的是常態,未分類是待補的例外。列表篩選要有「未分類」這個選項讓使用者找得到待補的。
D18 · 兩個懸空 i18n key:field_scope / hint_scope_system
FR-059 當初為「root 建立公版」預留了這兩個翻譯 key,但 UI 沒做——createDialog.scope 恆為 TENANT,root 目前沒有辦法從 UI 建公版(只能靠 migration seed)。
我的建議:補做 root 建公版的 UI,讓這兩個 key 活起來。
理由:本案要開放編輯基本資料(D8),編輯 Dialog 與新增 Dialog 是同一組欄位。既然都要重構那 600–700 行,順手把 scope 欄位補上(對非 root 隱藏或唯讀)成本極低。而且公版只能靠 migration 建本身就是 FR-059 未收乾淨的尾巴——每加一支公版又要寫 migration,正是 FR-059 立案時要消滅的維運模式。
被排除:標記刪除這兩個 key——那等於承認「公版只能靠 migration 建」是最終狀態,與 FR-059 的立案動機相牴觸。
若決策者認為範圍太肥:至少要在 FR-060 的 spec 明寫「公版建立目前仍走 migration」,不要讓這兩個 key 繼續懸空無人知曉。
✅ D19 · 多工具擴充邊界(決策者 2026-08-03 指示,已定調)
要預留擴充方式,以目前有的工具為主,未來如果又有多工具有需要設定檔的,也要可以擴充,不能像這次一樣又大幅度更新,上線後很危險。 — 決策者原話(2026-08-03)
工具維度本來就在,拆表不動搖它——detection_tool_id 是建表就有的 not-null 欄位,唯一索引 (detection_tool_id, tenant_id, name) 三欄含它,列表頁已有工具篩選、建立時必選工具、「複製到另一工具」整個功能繞著它轉。D1 建議把它放主檔(跟著「這支基準是什麼」走),行為零變化。
但目前只有 InSpec 系真的入庫。實查各工具參數 schema 的 profile 欄位取值來源:
| 工具 | 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,即 §2C 講的「使用者選錯工具後停用重建」殘留,是誤操作痕跡不是 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 表存正規化語意 + 原始中繼資料分離(欄位清單見 D12 改寫後版本)。正規欄只收所有格式一定都有的最小集合;格式差異全進 attributes jsonb;extraction_format 標記來源格式讓讀取端知道該期待什麼。
代價要講清楚:現在只有 InSpec 一種真實格式,正規化是「照一個實例抽象」,有抽錯風險。降低風險的做法是正規欄只做最小集合——不要為了預想中的 XCCDF 去猜欄位,猜錯比不猜貴。寧可多留在 attributes 裡,日後有第二個實例驗證後再提升為正規欄(那是加欄位,不是改語意,成本可接受)。
被排除:明寫「profile 庫只服務 InSpec 系」——太樂觀。決策者已明確表示未來其他工具的設定檔也會在這裡維護,把邊界劃死等於把問題推給下一次改表。
被排除:現在就完整抽象化(正規欄涵蓋預想中的 XCCDF 欄位)——只有一種真實格式時抽象化容易抽錯,沒有第二個實例可以驗證抽象是否正確。抽錯的抽象比沒有抽象更貴,因為它會誤導後續實作照錯的形狀寫。
對其他決策項的影響:D12 欄位定義已改寫(不用 impact 當欄位名);.1 建表採上述形狀;.3 抽取管線改註冊表骨架(工作量差異不大,都是新寫);D5 的「基準體系」軸(TWGCB/CIS/STIG/dev-sec)不受影響——CIS/STIG 本來就是跨格式的體系名。
本案不實作選用範圍、豁免、人工判定與自訂項,但資料模型必須現在就留好掛載點——這些是不可逆的架構決定,事後再改要動 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。
D12 建議的欄位集合(正規欄 + 完整中繼資料進 attributes jsonb)就是為此。存下來不顯示,成本是幾百 KB;沒存要重抽,成本是一整輪重跑加一支 migration。
D19 讓這條約束更強:重抽的成本不只是「每支 28~117 秒」——多工具之後,某些格式可能根本無法重抽(例如工具端 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 而非只是傳參數,複雜度較高,是它單獨成案的理由之一 |
切線在管理面/執行面之間,不是在「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 跨格式形狀:正規欄最小集合 + attributes jsonb + extraction_format / origin 雙標記)+ 13 筆三段式搬遷 + RLS 兩表八段 + repository / domain service / app service 改寫 + 派工鏈兩處破口修補(_load_profile 的 is_active 守門、_humanize_profile_param 的 name 退化) |
全案的地基,必須先行。風險最高的一項——rename 的是 STG 正在服役的表,且 STG 資料全 SYSTEM 測不到租戶路徑。驗收必須在 DEV 完成(DEV 才有 TENANT 資料與 32/33 陷阱)。controls 表欄位是本案最不可逆的決定(D19)——上線後要加格式只能加資料,不能改欄位語意 |
| FR-060.2 分類與編輯 |
分類三軸落地(D5)+ platform_hint 退役(D6)+ 編輯端點(D8)+ 管理頁重構 + 下拉分組 + fallback 比對修補(D13)+ 既有 13 筆分類補登(D17) |
依賴 .1 的主檔存在。與 .3 可並行(兩者動的是不同層:.2 動主檔與 UI 框架,.3 動從檔與詳細頁) |
| FR-060.3 抽取與瀏覽 |
BE 主機安裝 CINC(含 host-dependencies.md 補第三項)+ 抽取器註冊表骨架(D19,依 profile_format 取 extractor)+ InSpec extractor 實作(含三種失敗分支)+ 非同步管線 + extraction_status + 控制項清單詳細頁 + 摘要區(234/199/35) |
依賴 .1 的控制項表存在。主機依賴要提前備妥——三台部署機 + 每台開發機都要裝,這是實作前的準備動作不是實作內容。本期只實作 InSpec 一種 extractor,註冊表骨架是為了讓第二種格式進來時不必動管線 |
| FR-061.4 / .5 下一階段 |
選用範圍 / 豁免 / 人工判定 / 自訂項 | 另案。本案只定模型契約(§4),不實作 |
為什麼切線在這裡
.1–.3 最壞的情況是「詳細頁少一塊內容」——既有掃描零影響,因為這三項完全不動掃描參數,agent 拿到的 payload 形狀與今天一模一樣。
.4 開始就動掃描參數(--controls / --tags / --waiver-file),改錯會讓掃描結果不正確。在 GRC 系統裡,使用者以為自己合規但實際不是,是嚴重問題——這種風險等級的變更值得單獨立案、單獨驗收,不該混在管理面的改動裡一起上。
design.md(含正式拆卡)CINC_AUDITOR_CMD 環境變數)config.detection_tool_profiles 在 POC 的存在狀態與資料筆數,作為未來上版的基準🔴 環境紀律(不可省略)
開發期間所有 migration 只套 DEV。STG(已服役、有 10 筆資料)與 POC 一律等決策者當次明確指示才套。
「STG 跑得順」不能當驗收依據——STG 資料全 SYSTEM、無 TENANT,測不到租戶 RLS 路徑、測不到 32/33 的三欄 JOIN 陷阱、測不到停用列造成的孤兒主檔。驗收要在 DEV 做,那裡才有完整的資料形態。
下一步
決策者審閱本稿 → 逐項裁決 D1–D18(可直接採用建議或推翻)→ 我把定案寫進 design.md 並拆出 .1 / .2 / .3 三張卡 → 依序開工。