---
title: "原始碼弱點掃描與 SBOM 報表 — 需求討論稿 (FR-134)"
brand: "Guidant AI · **FR-134** 原始碼弱點掃描與 SBOM 報表"
eyebrow: "FR-134 · 需求討論稿 · 2026-10-10"
h1: "讓不懂程式的顧問，自己看得出專案有多危險"
subtitle: "上傳源碼包或現成 SBOM，既有沙箱裡跑 Syft／Trivy／Grype，產出白話報表"
lede: "顧問輔導客戶時，專案弱點本該由客戶工程師盤點回報，實務上常被一句「那個不嚴重」帶過。本案讓顧問**不靠工程師**就能拿到一份有工具原始輸出背書的弱點報表：產出 SBOM（軟體物料清單，列出程式用了哪些第三方套件與版本）、多工具比對弱點、事實統計（不替任何人下結論）、歷次比對、可交付的 PDF／DOCX。工具一律包現成的，功夫花在**沙箱隔離、離線弱點資料、把三家工具的輸出攤在同一列**。第一版只收上傳（源碼壓縮檔與 SBOM 檔），掃描工具併進既有沙箱、容器數不變；Git 來源列第二版。本稿盤點現況、畫出架構，拆成八個階段，記錄 14 項決策定案。"
chips: [
  {text: "14 項全數定案（D2／D10／D11 移未來項）", kind: ok},
  {text: "已定：工具併進既有沙箱，容器數不變", kind: ok},
  {text: "已定：不做燈號，只給事實統計", kind: ok},
  {text: "已定：弱點資料更新包（連網自動、封閉手動）", kind: ok},
  {text: "已定：第一版只收上傳，Git 列第二版", kind: ok},
  {text: "已定：SBOM 同時支援 CycloneDX 與 SPDX", kind: ok},
  {text: "已定：只做純靜態解析，缺 lock 檔列為發現", kind: ok},
  {text: "兩種輸入：源碼包／SBOM 檔", kind: accent},
  {text: "八階段 A～H 全在第一版", kind: accent},
  {text: "一個核心、兩個入口，同版交付", kind: accent},
  {text: "已接受取捨：拆檔與掃描共用一顆沙箱", kind: crit}
]
footer: "FR-134 · 原始碼弱點掃描與 SBOM 報表 — 需求討論稿 · 2026-10-10 · 草稿：[2026-10-10-sbom-scan-for-consultants.md](draft-requirement.md) · 現況依據：`docker/production/docker-compose.yml`、`scripts/build/sbom.sh`、`scripts/build/vuln_gate.py`、`scripts/build/build_bundle.sh`、`app/background_job/`、`jedi_detection/common/`、`core/host_capabilities.py`、`common/authz/license.py` 實檔查證"
---

**狀態**：討論稿定稿（14 項定案，見 design.md）　｜　**日期**：2026-10-10　｜　**白話需求草稿**：[`draft-requirement.md`](draft-requirement.md)

## 分工概述（30 秒版） {#overview nav="概述"}

整案一句話：**顧問把客戶的原始碼壓縮檔或現成的 SBOM 上傳進系統，十幾分鐘後拿到一份看得懂、工程師賴不掉的弱點報表；三個月後再掃一次，修了沒一眼看到。**

給非工程讀者的背景：現代程式九成以上是拼裝現成的第三方套件。套件被發現有安全漏洞（弱點）時，用到它的程式就跟著有風險。**SBOM** 是一份「這支程式用了哪些套件、哪個版本」的清單；拿這份清單去比對公開的弱點資料庫，就知道哪裡有洞、多嚴重、有沒有修正版。市面上的工具都會做這件事，但產出全是給工程師看的代號與分數。本案補的是「給顧問看的那一層」。

::: {.callout .decided}
**八個階段：A 先做，B 與 C 可平行，C 之後 D／E／H 三線平行**

A 把掃描工具裝進既有沙箱（不新增容器）；B（弱點資料更新包）與 C（後端核心）可同時開工；D（畫面）、E（報表）、H（檢測工具清單多一個工具）都接在 C 之後、彼此平行；F（歷次比對）要 C 與 D；G 收尾文件等全部完成。獨立模組與檢測工具兩個入口同一版交付。
:::

| 階段 | 做什麼 | 產出 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|
| **A 沙箱擴充** | 掃描工具 Syft、Trivy、Grype 裝進既有的檔案沙箱（不新增容器）；加一個放弱點資料的儲存區；沙箱的加固設定抽成共用樣板 | sandbox image 加工具、vulndb volume、compose 樣板、守衛測試擴充 | 進沙箱跑 `syft version`、`trivy --version`、`grype version` 三支都有；在沙箱裡 `curl https://github.com` 會失敗；守衛測試全綠 |
| **B 弱點資料更新** | build 機產出一包有簽章的「弱點資料更新包」；安裝包隨帶一份；封閉網路手動匯入，連網客戶在設定頁按「檢查更新」或排程自動下載同一份包 | 更新包、簽章驗證、匯入指令 `guidantai vulndb import`、設定頁檢查更新、升級流程 | 在拔網路線的機器上匯入更新包 → 系統設定頁顯示「弱點資料日期：YYYY-MM-DD」→ 掃一個已知有洞的範例專案，弱點數不是零；把更新包改一個位元組再匯入 → 被拒 |
| **C 掃描後端** | 資料表、背景工作、兩種輸入路徑（源碼包、SBOM 檔）、把三家工具的結果整理成同一套格式、SBOM 多格式多版本輸出、權限與授權、API | migration、job handler、正規化層、API | 用 API 送一個範例源碼包、一份 SBOM 檔，兩筆都跑完，查得到元件數、弱點數、事實儀表板六區塊的數字 |
| **D 結果與操作畫面** | 掃描對象清單、新增精靈三頁、結果頁三層分頁、四段白話進度 | 前端頁面 | 不看任何文件，照畫面新增一筆掃描，等進度走完四段，點進結果頁看得到事實儀表板六區塊統計，弱點清單表頭顯示目前的排序規則；畫面與競品無可辨識的版面雷同 |
| **E 報表與 AI 分析** | 三種樣式（顧問摘要／完整技術／客戶交付）× 三種格式（PDF／HTML／DOCX）；報告頁「AI 分析」按鈕接 FR-122 AI 閘道 | 報表產生器、一個提示樣板 | 匯出九種組合各一份，PDF 中文不缺字；任挑一個弱點，看得到哪個工具報的、NVD 連結、修到哪版；按「AI 分析」才出現一段標示「AI 生成」的文字，AI 呼叫紀錄多一筆 |
| **F 歷次比對** | 同一對象兩次掃描比：修掉／新增／仍在 | 比對頁、回訪報告 | 對同一範例專案升一個套件版本再掃，比對頁「修掉」欄出現那個弱點 |
| **G 收尾** | SPEC 頁、使用手冊、安裝文件、資安規則檔補路徑 | 文件 | 手冊找得到「怎麼新增掃描、怎麼更新弱點資料、每個數字的資料來源」三件事 |
| **H 檢測工具入口** | 檢測工具清單裡多一個工具「SBOM 弱點掃描」，與 OpenSCAP、SonarQube 並列；整條鏈都在：建任務時可選 → 任務頁執行 → 結果進檢測紀錄 → 轉證據。這個工具不派遠端 Agent，在我們自己的伺服器上跑，使用者不必選執行位置 | 工具目錄一列、伺服器端執行路徑、任務頁共用 | 專案規劃建檢測工具任務時，下拉能選「SBOM 弱點掃描」，而且不出現 Agent 指派欄；任務啟動或按執行後，不需任何 Agent 在線，執行列表出現一筆並跑完；結果檔出現在任務證據清單；點「看完整結果」進獨立模組結果頁，數字一致 |

依賴：A（沙箱擴充）→ B、C 平行；C 之後 D、E、H 平行；D 之後 F；B、D、E、F、H 全部完成後 G 收尾。

---

## 需求分析 {#analysis nav="需求"}

### 要解決的事

顧問沒有技術背景，問工程師「有沒有已知弱點」只能得到「都有在更新」。本案要讓顧問**自己**回答四個問題，而且答案有工具原始輸出撐腰：

1. 這個專案用了哪些第三方套件、哪個版本？（SBOM）
2. 其中哪些有已知弱點、多嚴重、有沒有人真的在攻擊它？（弱點比對＋KEV／EPSS）
3. 哪些已有修正版、修正版出來多久了？（可修未修統計）
4. 上次說要修的，修了沒？（歷次比對）

### 兩種輸入（Git 來源第二版）

| 輸入 | 誰會用 | 走哪條路 | 版次 |
|---|---|---|---|
| **上傳源碼壓縮檔**（zip／tar.gz，100MB 內） | 客戶給了一包程式碼 | 沙箱內安全解壓 → Syft 產 SBOM → 比對弱點 | 第一版 |
| **上傳現成 SBOM 檔**（CycloneDX／SPDX，JSON 或 XML，50MB 內） | 工程師或其他工具（Snyk、Dependency-Track、我們自己的出貨管線）交出來的清單 | 跳過 Syft，沙箱內驗格式 → Trivy、Grype 直接吃 SBOM 比對弱點 | 第一版 |
| 填 Git 位址（可含私有倉庫帳密） | 顧問拿得到 repo 讀取權限 | 抓檔容器 clone → 交給沙箱 → 同第一種 | **第二版**（見「未來項」節） |

SBOM 檔輸入的價值：顧問可以**驗工程師交來的 SBOM**——品質檢查（是否齊備 NTIA 最低要素，即美國商務部訂的 SBOM 七個必填項）對外來 SBOM 尤其有意義，「你交的清單連版本都沒寫齊」本身就是一個發現。兩條路在「正規化層」匯合，後面的結果頁與報表完全共用；第二版加 Git 時是在最前面多一段「抓檔」，正規化層之後不動。

### 與市面工具差在哪

工具本身（Syft、Trivy、Grype）是業界標準，不自己造。差異化全在**呈現層**：

- **同列對照**：同一個弱點，Trivy 怎麼說、Grype 怎麼說、分數差多少，攤在同一列，不是兩張表
- **事實統計、不下結論**：嚴重度、KEV、EPSS、可修未修、涵蓋完整度，全部來自工具與公開權威來源，沒有我們自訂的燈號（D6）
- **KEV／EPSS**：標出「已知被利用」與「被利用機率」，不是只看 CVSS 分數
- **歷次比對**：修掉、新增、仍在
- **可追溯**：每個數字都能點回工具原始輸出與檔案指紋

### 第一版的邊界

- **只收上傳**：源碼壓縮檔與 SBOM 檔。Git 來源（含私有倉庫憑證）列第二版——它需要一個可出網的抓檔容器與一整套憑證管理，第一版先把「上傳就能出報表」做紮實。
- **不新增容器**：掃描工具併進既有沙箱（D1）。
- **只做純靜態解析**：讀 lock 檔（套件鎖定檔，記錄每個套件的確切版本）與釘死版本的宣告檔，**不執行客戶的建置工具**（已定，理由見 D3）。沒有 lock 檔時照樣出報表，但明確標示涵蓋範圍不完整、SBOM 品質分扣分，並把「缺 lock 檔」列為一項供應鏈管理缺失的發現。

---

## 功能使用流程（顧問視角） {#usage nav="使用流程"}

::: {.callout .decided}
**UI 設計約束：自行設計，不仿照競品**

畫面版面、分頁結構、欄位命名、用語一律自行設計，依 Guidant 既有設計系統（`guidant-design-system` skill、PrimeVue 元件）與本文的顧問情境出發。**不得仿照 ITRI SecureScan 或任何競品的版面與結構**，競品截圖不進前端派工卡。**未實作的功能不在畫面上預告或留灰色入口**（例如 Git 來源在做出來之前，精靈、清單、設定頁都不出現任何 Git 字樣）。

> UI 不要直接抄襲，會沒辦法對外交代。
> [— 決策者原話（2026-10-10）]{.attr}
:::

主要入口在側選單「原始碼掃描」（以下五步都在這裡）。另一個入口是**檢測工具清單裡多一個工具「SBOM 弱點掃描」**，與 OpenSCAP、SonarQube 並列：專案規劃建檢測工具任務時從下拉選它（不出現 Agent 指派欄）→ 任務頁按執行 → 結果進該任務的檢測紀錄 → 轉成證據；不建掃描對象，「看完整結果」連到本模組的結果頁（D9）。整個流程**沒有工程師的位置**——顧問自給自足是賣點；三個月後回訪時，在同一個掃描對象下上傳新的壓縮檔即可重掃。

```{.mermaid cap="圖 1 — 顧問操作流程：清單、精靈、進度、結果、比對；設定頁由管理員維護"}
%%{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
  M["側選單：原始碼掃描"] --> L["① 掃描對象清單"]
  L -->|"新增"| W1["② 精靈頁 1：這是什麼"]
  W1 --> W2["頁 2：程式在哪<br/>源碼包／SBOM 檔"]
  W2 --> W3["頁 3：怎麼掃<br/>標準掃描，進階可勾工具與格式"]
  W3 --> P["清單上顯示四段進度<br/>接收檔案→產出物料清單→比對弱點→整理報告"]
  L -->|"重新掃描"| P
  P --> R["③ 結果頁<br/>顧問層／對照層／原始層"]
  L -->|"看結果"| R
  R -->|"匯出"| X["報表：樣式 × 格式"]
  L -->|"歷次比對"| H["④ 選兩次比：修掉／新增／仍在"]
  H -->|"匯出回訪報告"| X
  S["⑤ 設定頁（管理員）<br/>弱點資料・資源上限"] -.->|"弱點資料日期與上限"| W3
```

### ① 掃描對象清單

每一列是一個「掃描對象」（一套被掃的系統），跨稽核專案重用。

| 欄位 | 說明 |
|---|---|
| 對象名稱 | 顧問自取，如「客戶 X 內部請假系統」 |
| 客戶／備註 | 自由文字 |
| 來源 | 源碼包／SBOM 檔 |
| 最近掃描日 | 最後一次完成的日期 |
| 重點數字 | KEV 目錄內幾個、High 以上幾個（Trivy／Grype 取較多者並註明來源）——只放事實，不放燈號（D6） |
| 趨勢 | 與上一次比：修掉幾個、新增幾個 |

按鈕：新增、重新掃描、看結果、歷次比對。

### ② 新增掃描對象精靈（三頁）

**頁 1：這是什麼**——名稱、客戶備註、聯絡工程師（選填，只是備忘，系統不會聯絡他）。

**頁 2：程式在哪**（兩選一）

- **上傳源碼包**：zip／tar.gz，100MB 內。畫面提示「請確認壓縮檔包含 `package-lock.json`、`poetry.lock` 這類鎖定檔，沒有的話版本會不準——請工程師提供 lock 檔，或改用下方『上傳 SBOM 檔』」。
- **上傳 SBOM 檔**：CycloneDX 或 SPDX，JSON 或 XML，50MB 內，系統自動辨識格式與版本。

**頁 3：怎麼掃**——預設「標準掃描」（Syft 產清單，Trivy＋Grype 比對弱點，CycloneDX 與 SPDX 都產）。「進階」摺疊區可勾選要跑的弱點工具，以及要另外產出的 SBOM 格式與版本（CycloneDX 1.4～1.7、SPDX 2.2／2.3，兩種格式同時列出）。

送出後回到清單，該列顯示四段白話進度，對應背景工作的進度回報（`ctx.report_progress`）：

> 接收檔案 → 產出物料清單 → 比對弱點 → 整理報告

第一段是「檢查並解開上傳的檔案」。上傳 SBOM 檔時第二段顯示「已提供，略過」。

### ③ 結果頁（三層分頁）

::: grid2
::: {.card .ok}
#### 事實儀表板（預設）

六個區塊，只放事實、不下結論（D6）：

1. 嚴重度統計（Trivy、Grype 各一組並列）
2. 在 CISA KEV 目錄上的弱點（逐筆列出）
3. EPSS 達門檻的弱點（門檻顯示在旁，預設 10%）
4. 可修未修：已有修正版幾個、最早的修正版發布於何時
5. 涵蓋完整度：缺 lock 檔的生態系、NTIA 要素齊否
6. 跟上次比：修掉／新增／仍在

另有授權風險摘要（用到幾個傳染性授權的套件，如 GPL）。右上角「弱點資料日期」，超過 30 天黃色、90 天紅色。
:::
::: {.card}
#### 對照層

每個弱點一列，排序可選（預設 KEV → EPSS → CVSS，規則顯示在表頭）：

| 套件／版本 | Trivy | Grype | KEV／EPSS | 修正版 | 白話說明 |
|---|---|---|---|---|---|

兩家說法不同時，該列標「工具意見不一致」，點開看各自理由（D7）。
:::
:::

**原始層**：下載 SBOM（選 CycloneDX 或 SPDX、選版本）、下載各工具原始 JSON、工具版本與每份檔的 sha256 指紋。

**匯出報表**：選樣式（顧問摘要／完整技術／客戶交付）× 格式（PDF／HTML／DOCX）。

### ④ 歷次比對

選同一對象的任意兩次掃描，三欄並排：**修掉**／**新增**／**仍在**。可匯出「回訪報告」。

### ⑤ 設定頁（只給管理員）

- 弱點資料庫狀態（資料日期、各來源筆數）＋匯入更新包
- 資源上限（單次時限、上傳大小上限）

### 兩條設計要點

::: {.callout .decided}
**工程師不在流程裡；進度用白話四段**

顧問自給自足是本功能的賣點：上傳就能出報表，不必等工程師配合。進度不顯示「job running」這類字樣，一律四段白話，讓顧問知道現在卡在哪一步。
:::

---

## 現況盤點 {#inventory nav="現況盤點"}

| 元件 | 座標 | 現況 | 可複用 | 缺口 |
|---|---|---|---|---|
| 沙箱容器 | `docker/production/docker-compose.yml:580-622` | `guidant-sandbox`：唯讀、去全部權限、禁提權、tmpfs `/tmp` 2g、mem 2g／cpus 2／pids 256；身分靠 image 的 `USER classifier`，compose 未寫 `user:` | **掃描工具直接裝進這顆**（D1）；加固形狀抽成樣板 | 寫死在單一服務內，沒有樣板；記憶體 2g 不夠掃描（D12） |
| 沙箱網段 | `docker-compose.yml:743-749` | `guidant-sandbox`／`guidant-sandbox-errors` 皆 `internal: true` | 原樣沿用，掃描不需要出網 | — |
| 沙箱呼叫設定 | `docker-compose.yml:146-150`、`config/config.py:198-204`、`core/plugins/ai_gateway.py:185-214` | `SANDBOX_MODE`、`CLASSIFIER_SERVICE_URL` 在共用環境變數；行程內模式只准 DEV | 模式判斷與 DEV 限制 | — |
| 沙箱客戶端 | `jedi_ai_gateway/infra/extract/sandbox_client.py:36-72` | 只有 `.extract(job_uid, files, options)`，POST `/v1/extract` | 共享密鑰、逾時處理的寫法 | **只為拆檔設計，無通用執行介面**，要加掃描方法 |
| 沙箱服務 | `jedi-evidence-classification/docker/classifier_service.py` | stdlib http.server，`/healthz`、`/v1/extract` | 服務骨架，加一支 `/v1/scan` | 掃描端點要新寫 |
| 守衛測試（容器） | `test/security/code/test_security_shape_container_hardening.py:17,28-38` | 四個容器名寫死；只守 cap_drop／no-new-privileges／mem／pids | 斷言寫法 | **不守** read_only、tmpfs、cpus、網段、internal；未來新沙箱不會自動被納入 |
| 守衛測試（外呼） | `test/security/code/test_security_shape_external_calls.py:159-160` | 登記 `SandboxClient._remote::requests.post` | 登記格式 | 新增的掃描呼叫要登記 |
| installer 服務名 | `scripts/installer/install.sh:1209-1211,2144,2149,2548-2561`、`scripts/installer/guidantai:300,843`、`docker/production/.env.example:121-126` | 寫死 `guidant-sandbox` | 容器不增，服務名不必改 | 要加 vulndb volume 與更新包匯入 |
| 沙箱 image build | `scripts/build/build_classifier_image.sh`；寫死處 `sbom.sh:101`、`vuln_gate.py:19`、`build_all.sh:191`、`build_bundle.sh:93-108`、`push_harbor.sh:33,46` | 不走 Nuitka 編譯 | 在這支腳本加裝三支工具 | image 變大（三支工具本身，弱點資料不進 image）；出貨弱點掃描會一併掃到這三支工具 |
| 出貨 SBOM 管線 | `scripts/build/sbom.sh:44-104`，工具版本見 `scripts/build/README.md:354` | syft 1.54.0／trivy 0.75.0／grype 0.120.0 裝在 build 機；`--source` 複製 lock 檔到暫存目錄跑 `syft dir:` | **指令組合直接搬**（lock 檔掃法、Trivy／Grype 參數） | 只服務出貨，產品零處呼叫 |
| 弱點判定 | `scripts/build/vuln_gate.py:84,121-134` | 三工具任一白名單外 Critical 即擋；`relatedVulnerabilities` 收別名 | **別名收集（GHSA↔CVE）可借做跨工具配對** | 判定邏輯是「擋不擋」，不是「多危險」 |
| 離線弱點資料 | — | 每次掃描自動線上更新；Grype DB 約 3GB 在 `~/.cache/grype/db` | — | **完全沒有離線機制** |
| 安裝包 | `scripts/build/build_bundle.sh:8-27,258-297,369-385` | manifest 只對 `images/*.tar` 算 sha256；storage-seed 升級每次重灌 | storage-seed 的「非 image 資料隨包」做法；`ai_gateway_rules.sh:1-30` 的出廠預設與客戶修改分目錄 | **無「資料可單獨更新」先例** |
| 背景工作 | `infra/background_job/model/background_job.py:7-26`、`app/background_job/service/background_job_app_service.py:55`、`app/background_job/handler_registry.py:25` | 四態；`enqueue(job_type, payload, max_attempts=3)`；payload 不可放金鑰；租約 300 秒 | **整套直接用**；範本 `core/plugins/_evidence_classification_job_queue.py:19-33` | **沒有對外查工作狀態的 API** |
| 上傳限額與檢查 | `config/config.py:212-219`、`jedi_detection/common/detection_source_file.py:48-101`、`detection_profile_archive.py:99`、`archive_limits.py:51-99` | 100MB／解壓後 500MB／5 萬檔；副檔名白名單；zip-slip、符號連結、檔頭、壓縮炸彈檢查 | **限額與檢查器直接用** | **後端沒有「安全解壓到磁碟」函式**（解壓都在 agent 端） |
| 檔案儲存 | `jedi-file-upload` `ports.py:54`、`app/upload_file/service/managed_file_upload_service.py:35` | `save_file`／`get_file` | 原檔、原始輸出、報表都存這 | — |
| 祕密加密 | `infra/cloud_integration/crypto/fernet_crypto.py:6`、`config/config.py:187-195`、`jedi_detection/common/detection_secret_params.py:62-130` | 三把 Fernet 金鑰；偵測域的 envelope 寫法 `{"__enc__":"fernet","value":...}` | 第二版 Git 私有倉庫憑證用 | 第一版不需要 |
| PDF 報表 | `api/project_summary_report/routes/project_summary_report_route.py:163-182` | WeasyPrint、bleach 消毒、只放行 data URI（防伺服器端請求偽造）、Noto Sans CJK TC | 整條產生方式 | **沒有共用的品牌頁首頁尾** |
| DOCX 報表 | `app/oscal/service/export/ssp_docx_generator.py:46`、`ssp_libreoffice_converter.py:57` | docxtpl＋python-docx；LibreOffice 轉 PDF | 範本填值方式 | — |
| 差異比對 | `app/oscal/service/import_diff/ssp_diff_service.py:44` | unchanged／changed／added／gone，以 key 配對 | 四態比對骨架 | 偵測結果沒有逐筆指紋、沒有跨次比對 |
| 新模組接線 | `config/app_modules.py`、`di_containers/containers.py:145-146` | 四層樣板 commit `6a35e2cf3`；薄樣板 commit `98cc3c934` | 樣板 | — |
| 能力點 | `core/host_capabilities.py`、`scripts/deliverables/gen_permission_matrix.py`、範例 `scripts/sql/2026-10-05-fr129-2-tenant-scan-zones.sql` | `<模組>.<資源>.<動詞>`；加值包必須整包落在單一模組 | 整套 checklist | — |
| 授權照 | `common/authz/license.py:107-125,352` | `require_license`；基礎設施豁免清單 | 守門 | 歸既有檢測工具包（D8） |
| RLS | FR-129.2 migration `:21-60`；守衛 `test/security/database/test_rls_all_tenant_tables.py` | 前綴比對＋超管放行，FOR ALL 一條規則涵蓋增查改刪 | 照抄形狀 | — |
| 偵測結果表 | `scripts/sql/2026-07-26-fr056-4-detection-executions.sql` | `detection_executions` 綁任務（`job_execution_uid NOT NULL`）、`summary JSONB`、`report_file_id`、`agent_task_uid` | 檢測工具入口的結果落點（D9、階段 H）；伺服器端執行時 `agent_task_uid` 留空 | — |
| 檢測工具目錄 | `config.detection_tools`；清單 `GET /detection-tools`（`jedi_detection/api/routing.py:69`）只列 `status='available'` 且啟用；參數 schema `config.detection_tool_param_schemas`；任務綁定寫 `config.job_execution_detection_tools`（`detection_job_binding_handler.py:164`），一任務只綁一工具 | 建任務只在 `job_type='detection_tool'` 顯示工具下拉（FE `components/grc/planning/AoTaskForm.vue:472-475`） | 清單、參數 schema、綁定整套沿用 | **本案動作**：加欄位 `execution_target`（`agent`／`server`，預設 `agent`，既有列不動）；seed 一列 `sbom-scan`；`AoTaskForm` 依此欄隱藏 Agent 指派區塊 |
| 檢測派工 | `jedi_detection/app/service/detection_orchestration_service.py:184` `start_execution` → `_dispatch_one`（:464）建 Agent 工單；`_pick_agent`（:1417）找不到回 `NO_AVAILABLE_AGENT`；入口 `POST /detection-tools/jobs/<job_uid>/execute`（`detection_tool_route.py:178`，守門 `require_license("plugin")`＋`grc.job.update`）；任務啟動自動首掃（`app/detection_tools/task_type_declaration.py:43`） | **一律派給遠端 Agent** | 入口、守門、首掃觸發、`detection_executions` 建列 | **本案動作**：`execution_target=='server'` 時分岔到新檔（見風險節），入列 `background_jobs`（job_type `sbom_scan`），不找 Agent |
| 檢測結果轉證據 | Agent 回報 → `detection_lifecycle_listener.py:24` → `DetectionResultHandler.handle_result`（`detection_result_handler.py:71`）存 `JOB_EVIDENCES/<job_uid>` → 寫 `job_evidences`（source=`DETECTION_TOOL`）→ 回填 `detection_executions.report_file_id／evidence_id／summary` | — | **整支直接呼叫**，轉證據流程零改動 | **本案動作**：worker 跑完以多檔結果（報表、SBOM、原始 JSON）呼叫它 |
| 檢測檔案輸入 | 參數欄位沒有通用的檔案型別（FE `DetectionConfigField.vue:186-265`）；FR-058.7 SonarQube 寫死 `scan_mode='upload'`，走隱藏的檔案選擇上傳 `upload_type:'DETECTION_SOURCE'`，執行時送 `source_files`（FE `JobExecutionDrawer.vue:343-410,470-500`；BE `_resolve_row_source_files` :718） | — | 上傳機制沿用 | 不新造檔案欄位型別 |
| 源碼上傳掃描 | FR-058.7（jedi-detection `scan_mode=upload`，sonar） | 已上線 | 限額與檢查器（D13） | 解壓在 agent，不在後端 |
| 目標授權 | `core/plugins/remote_agent.py:19,87,215`（FR-129 `allowed_targets`） | 掃描目標必須來自登記資料 | 第二版 Git 站台白名單的形狀（未來項） | 第一版不需要 |
| 內部憑證 | FR-130：`/etc/guidant/internal-ca.crt` 掛載慣例 | 不碰沙箱 | 第二版抓檔容器信任客戶自簽 Git 站台時可借 | 第一版不需要 |
| 前端樣板 | FE commit `6ae57765`（14 檔：router、api.js、Service、config、views、i18n） | 選單由後端 `ui_routes` 驅動 | 整套 | — |
| 角色 | shipped roles：系統管理員／稽核主管／稽核員／一般成員／唯讀 | **沒有「顧問」角色** | 顧問對應系統管理員或稽核主管 | — |

---

## 業界對照：ITRI SecureScan（ARGYUS） {#benchmark nav="業界對照"}

工研院的 SecureScan 是國內同類產品，這裡只作為參考，設計以草稿中的顧問情境為準。

### 它有什麼

- 一次掃描一份報告，分頁包含風險檢視、SBOM 報告、AI 摘要、執行紀錄。
- 用 Syft 產清單，Trivy 與 Grype 查弱點；兩家結果分在不同子頁各自呈現。
- 弱點欄位以英文原始資料為主（弱點編號、套件、版本、修正版、嚴重度、英文說明），統計為各嚴重度計數。
- SBOM 頁直接呈現 bom-ref、cpe、purl 這類機器識別碼；有「品質異常」狀態字。

### 我們不同在哪

- **使用者不同**：它的呈現對象是看得懂弱點資料的人；本案從「沒有技術背景的顧問要跟工程師對話」出發，所以每個數字都要附白話與出處（D6、D7）。
- **兩家工具攤在同一列**：同一個弱點兩家各怎麼說直接對照，意見不一致時標出來（對照層）。
- **公開權威來源的事實**：加入 CISA KEV、EPSS、可修未修統計、涵蓋完整度，並附資料來源說明，不自訂燈號（D6）。
- **歷次比對**：同一對象的回訪比對，修掉／新增／仍在（階段 F）。
- **缺 lock 檔本身是發現**：涵蓋不完整明確標示，並對應 ISO 27001 管理缺失（D3）。
- **AI 分析是使用者主動按的**，預設畫面只放事實（D14）。

工具組合（Syft、Trivy、Grype）是業界通用做法，兩邊相同並非參照它而來，出貨管線早已在用（FR-127／FR-128）。

::: {.callout .decided}
**UI 設計約束：自行設計，不仿照競品**

畫面版面、分頁結構、欄位命名、用語一律自行設計，依 Guidant 既有設計系統（`guidant-design-system` skill、PrimeVue 元件）與本文的顧問情境出發。**不得仿照 ITRI SecureScan 或任何競品的版面與結構**，競品截圖不進前端派工卡。**未實作的功能不在畫面上預告或留灰色入口**（例如 Git 來源在做出來之前，精靈、清單、設定頁都不出現任何 Git 字樣）。

> UI 不要直接抄襲，會沒辦法對外交代。
> [— 決策者原話（2026-10-10）]{.attr}
:::

---

## 端到端流程 {#flow nav="流程"}

兩種輸入在「正規化層」匯合。後端行程**不解壓、不解析任何客戶檔案**，一律交給沙箱。

```{.mermaid cap="圖 2 — 端到端時序：兩條輸入路徑，在既有沙箱跑完工具後於 worker 正規化匯合"}
%%{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
    participant C as 顧問瀏覽器
    participant API as guidant-api
    participant OBJ as SeaweedFS
    participant DB as guidant-db
    participant W as guidant-worker
    participant S as guidant-sandbox

    C->>API: 新增掃描（上傳源碼包或 SBOM 檔）
    API->>OBJ: 存原檔（先擋大小與副檔名）
    API->>DB: 建掃描列、background_jobs 入列（payload 只放 uid）
    API-->>C: 回掃描 uid
    W->>DB: 撿單（FOR UPDATE SKIP LOCKED）
    Note over W: 進度①接收檔案
    W->>OBJ: 取原檔寫入共用工作 volume
    Note over W: 進度②產出物料清單
    W->>S: 內網掃描請求（工作路徑、輸入類型）
    alt 源碼包
        S->>S: 安全解壓、syft 產 SBOM
    else SBOM 檔
        S->>S: 辨識格式版本、驗 schema
    end
    Note over W: 進度③比對弱點
    S->>S: trivy sbom 與 grype sbom（離線弱點資料）
    S->>S: syft convert 產 CycloneDX 與 SPDX 各版本
    S-->>W: 原始輸出與 SBOM 檔（共用工作 volume）
    Note over W: 進度④整理報告
    W->>W: 正規化、跨工具配對、KEV／EPSS、六區塊統計、品質分
    W->>OBJ: 存原始輸出與 SBOM（附 sha256）
    W->>DB: 寫元件、弱點、摘要，掃描列標完成
    C->>API: 輪詢掃描狀態
    API-->>C: 四段進度與結果
```

---

## 架構 {#architecture nav="架構"}

### 現況與新增對照

先看落地版現在有哪些服務，再看本案動了哪裡。下面兩張圖依 `docker/production/docker-compose.yml` 實查繪製：現況共 10 個服務（其中 `guidant-db-init` 只在裝機／升級時跑一次）、3 個網段、4 個 named volume。

```{.mermaid cap="圖 3-1 — 現況：落地版所有服務（依網段分組；虛線為第二網段或只在升級時發生的連線）"}
%%{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
  BR["瀏覽器<br/>顧問／管理員操作介面"]
  AG["遠端 Agent（FR-039）<br/>客戶主機上的檢測代理"]
  HB["Harbor 映像倉庫<br/>只在升級時拉 image"]
  subgraph NDEF["網段 default（一般內網，非 internal；只有 guidant-fe 對外開 80／443）"]
    FE["guidant-fe<br/>nginx：唯一對外入口，SPA＋同源反代"]
    API["guidant-api<br/>REST API，收單、查狀態"]
    SIO["guidant-socketio<br/>即時通知"]
    WK["guidant-worker<br/>背景工作：撿單、證據分類、經 AI 閘道打 AI"]
    DB[("guidant-db<br/>PostgreSQL 16：主庫＋bugsink 庫")]
    RD[("guidant-redis<br/>Redis 8：快取與佇列，只開 TLS")]
    SW[("guidant-seaweedfs<br/>物件儲存：上傳檔與報表")]
    BS["guidant-bugsink<br/>錯誤追蹤站"]
    INIT["guidant-db-init<br/>一次性：裝機建庫、升級套 migration"]
  end
  subgraph NSB["網段 guidant-sandbox（internal，不能出網）"]
    SB["guidant-sandbox<br/>檔案沙箱：拆不可信證據檔"]
  end
  subgraph NERR["網段 guidant-sandbox-errors（internal）"]
    ERRL["成員只有沙箱與 Bugsink<br/>沙箱送錯誤事件專用"]
  end
  subgraph VOL["named volume（資料落點）"]
    VPG[("guidant-pgdata<br/>DB 資料")]
    VSWD[("guidant-seaweedfs-data<br/>物件儲存資料")]
    VCJ[("guidant-classifier-jobs<br/>分類工作暫存<br/>api／socketio／worker／sandbox 共掛")]
    VBS[("guidant-bugsink-data<br/>錯誤站資料")]
  end
  BR -->|"https 443"| FE
  AG -->|"https 443：註冊、回傳結果"| FE
  HB -.->|"只在升級時拉新版 image（各服務同）"| API
  FE -->|"/api/1.0"| API
  FE -->|"/socket.io"| SIO
  FE -->|"/bugsink/"| BS
  API --> DB
  API --> RD
  API --> SW
  SIO --> DB
  SIO --> RD
  WK --> DB
  WK --> RD
  WK --> SW
  WK -.->|"經第二網段 guidant-sandbox：內網 8080 拆檔請求"| SB
  API -->|"錯誤事件（socketio／worker 同）"| BS
  SB -.->|"第二網段：送錯誤事件"| ERRL
  ERRL -.-> BS
  BS -->|"bugsink 庫"| DB
  INIT -->|"profile init，跑完退場"| DB
  DB --- VPG
  SW --- VSWD
  WK --- VCJ
  SB --- VCJ
  BS --- VBS
```

**怎麼看這張圖**：

- 對外只有 `guidant-fe` 一個入口（80／443），瀏覽器與遠端 Agent 都從這裡進，再由 nginx 轉給 `guidant-api`、`guidant-socketio`、`guidant-bugsink`。
- `guidant-sandbox` 只在 internal 網段 `guidant-sandbox`（成員只有它和 worker），連不到 DB、Redis、物件儲存，也出不了網；另一個 internal 網段 `guidant-sandbox-errors` 只讓它把錯誤事件送給 Bugsink。
- Harbor 不是常駐連線，只有升級時從映像倉庫拉新 image。

```{.mermaid cap="圖 3-2 — 本案新增（淺黃色）：掃描工具併進既有沙箱，只多一個弱點資料 volume，容器數不變"}
%%{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
  BR["瀏覽器<br/>顧問／管理員操作介面"]
  AG["遠端 Agent（FR-039）<br/>客戶主機上的檢測代理"]
  HB["Harbor 映像倉庫<br/>只在升級時拉 image"]
  subgraph NDEF["網段 default（一般內網，非 internal；只有 guidant-fe 對外開 80／443）"]
    FE["guidant-fe<br/>nginx：唯一對外入口，SPA＋同源反代"]
    API["guidant-api<br/>REST API，收單、查狀態"]
    SIO["guidant-socketio<br/>即時通知"]
    WK["guidant-worker<br/>背景工作：撿單、證據分類、派掃描、產報表"]
    DB[("guidant-db<br/>PostgreSQL 16：主庫＋bugsink 庫")]
    RD[("guidant-redis<br/>Redis 8：快取與佇列，只開 TLS")]
    SW[("guidant-seaweedfs<br/>物件儲存：上傳檔與報表")]
    BS["guidant-bugsink<br/>錯誤追蹤站"]
    INIT["guidant-db-init<br/>一次性：裝機建庫、升級套 migration"]
  end
  subgraph HARD["套用共用加固樣板（唯讀、去權限、資源上限）；未來新沙箱套同一份"]
    subgraph NSB["網段 guidant-sandbox（internal，不能出網）"]
      SB["guidant-sandbox：拆檔＋SBOM 弱點掃描<br/>image 擴充：加 Syft・Trivy・Grype"]
    end
  end
  subgraph NERR["網段 guidant-sandbox-errors（internal）"]
    ERRL["成員只有沙箱與 Bugsink<br/>沙箱送錯誤事件專用"]
  end
  subgraph VOL["named volume（資料落點）"]
    VPG[("guidant-pgdata<br/>DB 資料")]
    VSWD[("guidant-seaweedfs-data<br/>物件儲存資料")]
    VCJ[("guidant-classifier-jobs<br/>分類工作暫存<br/>api／socketio／worker／sandbox 共掛")]
    VBS[("guidant-bugsink-data<br/>錯誤站資料")]
    VDB[("guidant-scan-vulndb<br/>弱點資料庫，D4 更新包匯入")]
  end
  BR -->|"https 443：上傳源碼包／SBOM 檔"| FE
  AG -->|"https 443：註冊、回傳結果"| FE
  HB -.->|"只在升級時拉新版 image（各服務同）"| API
  FE -->|"/api/1.0"| API
  FE -->|"/socket.io"| SIO
  FE -->|"/bugsink/"| BS
  API --> DB
  API --> RD
  API -->|"存上傳原檔"| SW
  SIO --> DB
  SIO --> RD
  WK --> DB
  WK --> RD
  SW -->|"取上傳原檔"| WK
  WK -.->|"經第二網段 guidant-sandbox：內網 8080 拆檔／掃描請求"| SB
  SB -->|"唯讀掛載"| VDB
  API -->|"錯誤事件（socketio／worker 同）"| BS
  SB -.->|"第二網段：送錯誤事件"| ERRL
  ERRL -.-> BS
  BS -->|"bugsink 庫"| DB
  INIT -->|"profile init，跑完退場"| DB
  DB --- VPG
  SW --- VSWD
  WK --- VCJ
  SB --- VCJ
  BS --- VBS
  style SB fill:#fff3cd,stroke:#d39e00
  style VDB fill:#fff3cd,stroke:#d39e00
  style HARD fill:#ffffff,stroke:#d39e00,stroke-dasharray:6 4
```

| 新增元素 | 是什麼 | 為什麼獨立 |
|---|---|---|
| `guidant-sandbox` image 擴充 | 既有檔案沙箱的 image 加裝 Syft、Trivy、Grype，同一顆容器同時負責拆證據檔與 SBOM 弱點掃描 | 不另開容器：上傳的源碼包與 SBOM 檔一樣是不可信的客戶檔，正好落在沙箱既有的隔離邊界內（internal 網段、唯讀、去權限、資源上限），直接沿用最省事也最一致 |
| named volume `guidant-scan-vulndb` | Trivy／Grype 弱點資料庫與 KEV、EPSS 資料，由 D4 的弱點資料更新包匯入，唯讀掛進 `guidant-sandbox` | 弱點資料以天為單位更新、產品版本以月為單位，放 volume 才能不出新版產品就換資料；燒進 image 會讓每次升級重拉數 GB |

**容器數不變**：第一版只收上傳（源碼包＋SBOM 檔），不做 Git 來源，因此不需要可出網的抓檔容器。沙箱改吃共用加固樣板後，未來若真要新增沙箱（例如 Git 抓檔），套同一份樣板即可。

### 掃描架構細節

```{.mermaid cap="圖 3 — 掃描架構：工具與弱點資料都在既有沙箱裡；只有 worker 碰 DB 與物件儲存，沙箱不能出網"}
%%{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
  BR["瀏覽器<br/>獨立模組頁／稽核任務頁"] -->|"443：上傳源碼包／SBOM 檔"| NG["guidant-fe（nginx）"]
  NG --> API["guidant-api<br/>入口 1 模組 API・入口 2 檢測工具執行<br/>（execution_target=server 不派 Agent）"]
  API --> PG[("guidant-db<br/>掃描表・background_jobs<br/>detection_executions")]
  API -->|"存原檔"| OBJ[("SeaweedFS<br/>原檔・原始輸出・報表")]
  WK["guidant-worker<br/>撿單・正規化・產報表"] --> PG
  OBJ -->|"取原檔"| WK
  WK -->|"存原始輸出與報表"| OBJ
  subgraph INT["網段 guidant-sandbox：internal，不能出網"]
    SB["guidant-sandbox<br/>拆證據檔＋Syft・Trivy・Grype"]
  end
  WK -->|"內網 8080：掃描請求"| SB
  WK -->|"寫輸入"| WV[("volume<br/>guidant-classifier-jobs<br/>既有共用工作區")]
  WV -->|"讀輸入、寫輸出"| SB
  VDB[("volume<br/>guidant-scan-vulndb")] -->|"唯讀"| SB
  CLI["guidantai vulndb import"] -->|"匯入更新包"| VDB
```

| 容器 | 網路 | 有什麼 | 沒有什麼 |
|---|---|---|---|
| `guidant-sandbox`（既有，擴充） | 既有 internal 網段（`guidant-sandbox`、`guidant-sandbox-errors`），不能出網 | 拆檔工具＋Syft、Trivy、Grype、唯讀弱點資料 | 對外網路、DB 連線、金鑰 |
| `guidant-worker`（既有） | 既有 | 派掃描、正規化、產報表 | 不解壓、不解析客戶檔案 |

工作暫存沿用既有的 `guidant-classifier-jobs` volume，不另開。第二版加 Git 時才會多一個可出網的抓檔容器，形狀見「未來項」節。

---

## 資料模型草案 {#data-model nav="資料模型"}

共同欄位（每張租戶表都有）：`id`、`uid`、`tenant_id NOT NULL`、`org_unit_id`、`created_user`／`updated_user`、`created_at`／`updated_at`。RLS 照 FR-129.2 的形狀：一條 FOR ALL 規則、USING 與 WITH CHECK 都寫，增查改刪四命令都有規則；守衛 `test_rls_all_tenant_tables.py` 會自動掃到新表。新表 `GRANT ... TO cm_app` 含 sequence。

| 表 | 用途 | 關鍵欄位 | 關聯 |
|---|---|---|---|
| `compliance.source_scan_targets` | 掃描對象（清單上一列） | `name`、`client_note`、`contact_note`、`source_type`（`upload`／`sbom`，預留 `git`）、`source_ref`（JSONB，來源細節）、`latest_scan_uid` | 1 對多 scans |
| `compliance.source_scans` | 一次掃描 | `target_uid`（**可空**，檢測工具入口不建對象直接跑）、`detection_execution_uid`（**可空**，從檢測工具入口跑時對應 `detection_executions`）、`source_type`、`source_ref`（JSONB，當次來源快照）、`input_file_id`、`status`、`background_job_uid`、`tools`（JSONB）、`tool_versions`（JSONB）、`vulndb_date`、`summary`（JSONB：六區塊統計）、`sbom_quality`（JSONB）、`raw_outputs`（JSONB：檔案 id＋sha256）、`report_file_id`、`error`、`started_at`、`finished_at` | 多對 1 target；1 對多 components／findings |
| `compliance.source_scan_components` | SBOM 裡的套件 | `scan_uid`、`purl`、`name`、`version`、`ecosystem`、`licenses`（JSONB）、`license_risk` | 多對 1 scan |
| `compliance.source_scan_findings` | 正規化後的弱點（一列一個「套件×弱點」） | `scan_uid`、`component_purl`、`vuln_id`（以 CVE 為主）、`aliases`（JSONB）、`severity`、`trivy`（JSONB：有無、等級、分數）、`grype`（JSONB）、`fixed_versions`、`in_kev`、`epss`、`match_key`、`fingerprint` | 多對 1 scan |
| `public.scan_vulndb_imports` | 弱點資料匯入紀錄（平台層，**無 tenant_id**） | `bundle_date`、`sha256`、`sources`（JSONB：各來源日期與筆數）、`imported_by`、`imported_at` | — |

**來源欄位可擴充，日後加 git 不改資料模型**：`source_type` 用字串值，第一版只有 `upload`（源碼壓縮檔）與 `sbom`（SBOM 檔）。**第一版 UI 與 API 枚舉只開放 `upload`／`sbom`，不接受 `git` 值**（送 `git` 一律回參數錯誤）；第二版加 `git` 時，位址、分支、commit、憑證 uid 都放進 `source_ref`（JSONB），既有表不加欄位。Git 站台白名單與租戶憑證兩張設定表屬第二版，見「未來項」節。

兩個關鍵 key：

- **`match_key`（跨工具配對）**：`ecosystem + 套件名 + 版本 + 正規化弱點編號`。正規化弱點編號用別名表收斂（GHSA、PYSEC 對到 CVE），做法借 `vuln_gate.py:84` 收 `relatedVulnerabilities` 的寫法。
- **`fingerprint`（歷次比對）**：`ecosystem + 套件名 + 正規化弱點編號`，**不含版本**——升了版但仍在受影響範圍內，算「仍在」而不是「修掉一個、新增一個」。

`summary` 的形狀刻意對齊 `detection_executions.summary`，兩個入口（D9）共用同一份摘要：檢測工具入口跑完時，`source_scans` 存完整結果（`target_uid` 空、`detection_execution_uid` 填上），`detection_executions` 經 `DetectionResultHandler` 存摘要、報告檔與證據 id。

---

## 階段拆分 {#breakdown nav="拆分"}

| 階段 | 做什麼 | repo | 依賴 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|---|
| **A 沙箱擴充** | 既有 `guidant-sandbox` image 加裝 Syft、Trivy、Grype（容器數不變），加一支掃描端點 `/v1/scan`；新增 named volume `guidant-scan-vulndb`（唯讀掛入沙箱）；共用加固設定抽成 compose 樣板 `x-guidant-sandbox-hardening`，`guidant-sandbox` 改吃樣板；沙箱資源上限依 D12 調整；守衛測試改為自動納入名字以 `-sandbox` 結尾的服務，並補唯讀與 internal 網段斷言；installer、bundle 加 volume 與更新包位置 | BE＋jedi-evidence-classification | 無 | 進沙箱 `syft version`、`trivy --version`、`grype version` 三支都有；沙箱內 `curl` 外網失敗；守衛測試全綠，且把沙箱的 `read_only` 拿掉會紅；證據分類照常可用 |
| **B 弱點資料更新** | build 機產出更新包（Trivy DB、Grype DB、CISA KEV JSON、EPSS CSV）＋sha256 manifest＋簽章；安裝包隨帶一份；`guidantai vulndb import <檔>` 驗簽章與指紋後寫入 volume；連網客戶由 api／worker 從自家站台下載同一份包、走同一條匯入（沙箱不出網）；日期較舊的包拒收；系統設定頁顯示資料日期與「檢查更新」 | BE | A | 斷網機器匯入後，設定頁顯示日期；掃範例專案弱點數非零；拿舊日期的包、或改過一個位元組的包匯入都會被拒；連網機器按「檢查更新」拿到同一份包，沙箱仍連不到外網 |
| **C 掃描後端** | 資料表與 migration；job handler（`max_attempts=1`）；掃描呼叫（登記外呼守衛）；**兩條輸入路徑（源碼包、SBOM 檔）與 SBOM 格式辨識**；安全解壓函式（D13）；正規化層（三工具 → 統一弱點模型、跨工具配對、KEV／EPSS、六區塊統計、NTIA 品質分、授權風險）；`syft convert` 多格式多版本輸出；能力點、授權照；API（含掃描狀態查詢） | BE＋jedi-detection | A（B 未完成前可在 DEV 用線上資料） | 兩種輸入各送一筆都跑完；同一份 SBOM 檔與它的源碼包掃出的弱點數一致；無權限角色打 API 回 403；`source_type` 存 `upload`／`sbom` |
| **D 前端** | 掃描對象清單、新增精靈三頁、四段進度、結果頁三層分頁、設定頁 | FE | C | 照畫面新增一筆、進度走完、結果頁看得到事實儀表板六區塊、排序可切換且表頭顯示規則；唯讀角色看不到「新增」；畫面與競品無可辨識的版面雷同 |
| **E 報表與 AI 分析** | 三種樣式 × PDF／HTML／DOCX；每個弱點列標工具、CVE（連 NVD）、advisory 連結、修正版；附錄一頁（工具與版本、弱點資料日期、原始報告指紋與下載位置）＋資料來源說明一頁；共用品牌頁首頁尾；報告頁「AI 分析」按鈕接 FR-122 AI 閘道（只加提示樣板） | BE＋FE | C | 九種組合各匯出一份，中文不缺字；報表裡沒有任何結論句；按「AI 分析」才呼叫，結果標示「AI 生成，以上方統計為準」 |
| **F 歷次比對** | 兩次掃描比對（借 `SspDiffService` 骨架）；比對頁；回訪報告 | BE＋FE | C、D | 升一個套件版本再掃，「修掉」欄出現該弱點；換一個仍受影響的版本，該弱點留在「仍在」 |
| **G 收尾** | SPEC 頁、使用手冊、安裝文件（更新包怎麼匯入）、`.claude/rules/security/` 補新模組路徑 | BE | A–F | 手冊三件事找得到 |
| **H 檢測工具入口** | 工具目錄 `config.detection_tools` 加 `execution_target`（`agent`／`server`，預設 `agent`）並 seed `sbom-scan` 一列；參數 schema 一份（`scan_mode`、`sbom_formats`、`tools`）；`AoTaskForm` 依 `execution_target` 隱藏 Agent 指派；`start_execution` 遇 `server` 分岔到新檔、入列 `background_jobs`；worker 跑完呼叫 `DetectionResultHandler.handle_result`；任務頁抽屜 summary 中文對照加 SBOM 的 key、加「看完整結果」連結 | BE＋jedi-detection＋FE | C（與 D、E 平行） | 專案規劃建檢測工具任務時下拉能選「SBOM 弱點掃描」且不出現 Agent 指派欄；任務啟動或按執行後，不需任何 Agent 在線，執行列表出現一筆並跑完；結果檔出現在任務證據清單；點「看完整結果」進獨立模組結果頁，數字一致 |

```{.mermaid cap="圖 4 — 階段依賴"}
%%{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
  A["A 沙箱擴充"] --> B["B 弱點資料更新"]
  A --> C["C 掃描後端"]
  C --> D["D 前端"]
  C --> E["E 報表與 AI 分析"]
  C --> F["F 歷次比對"]
  D --> F
  B --> G["G 收尾"]
  D --> G
  E --> G
  F --> G
  C --> H["H 檢測工具入口"]
  H --> G
```

---

## 決策定案 D1～D14（D2／D10／D11 已移未來項） {#decisions nav="決策"}

::: {.callout .decided}
**D1 沙箱怎麼擴充（已定：掃描工具併進既有沙箱，不新增容器）**

**已定事項：掃描工具併進既有沙箱，不新增容器**

- Syft、Trivy、Grype **併進既有 `guidant-sandbox` 的 image**，容器數不變。
- 新增 named volume `guidant-scan-vulndb` 放弱點資料庫，唯讀掛入沙箱。
- 共用加固設定（唯讀、去全部權限、禁提權、tmpfs、資源上限、執行身分）抽成 compose 的 x-anchor 樣板 `x-guidant-sandbox-hardening`。本案只有一顆套用，但未來新沙箱套同一份。樣板除 image、網段、volume 三個參數外，預留第四個「出網政策」（egress policy）：第一版只有 `none`；第二版 Git 抓檔容器用 `git-allowlist`；若日後做建置型依賴解析再加 `registry-allowlist`。
- 守衛測試改為「名字以 `-sandbox` 結尾的服務自動納入」，並補唯讀與 internal 網段斷言。
- 沙箱記憶體上限從 2g 提高到 4g（D12）。

**合併原則（之後新增容器都照這條判）**：容器只按三個信任邊界分——**能不能出網、碰不碰客戶輸入、有沒有金鑰**。三個答案都一樣就合併進同一顆，**不按功能開新顆**。拆證據檔與掃描源碼包三個答案完全相同（不出網、碰客戶輸入、無金鑰），所以合併；第二版的 Git 抓檔容器「要出網」，答案不同，才另開。

**被排除的做法**

- 另開一顆掃描沙箱：**排除**——容器數膨脹（決策者點名的顧慮），而它的信任邊界與既有沙箱完全相同，多一顆換不到額外隔離。
- 每個容器各寫一份完整加固設定：**排除**——下一個沙箱出現時就會有人漏一行，守衛測試又是寫死清單，不會發現。

**已接受的取捨**：拆證據檔與弱點掃描共用一顆沙箱，大型源碼包掃描期間，證據分類要排隊。緩解靠兩件事：沙箱的 `--max-concurrent` 同時處理數，以及背景工作佇列本身的排隊機制（兩種工作都經 `background_jobs`，不會互相搶死）。若實測排隊時間不可接受，再依合併原則評估——但那時拆開的理由是「資源」，不是「信任邊界」，優先考慮調高同時處理數。
:::

::: {.callout .decided}
**D3 第一版支援哪些語言生態系（已定：純靜態解析，Python／npm／Java 標已驗）**

**已定事項：第一版只做純靜態解析，不執行客戶的建置工具**

只讀 lock 檔與釘死版本的宣告檔（如 `pom.xml` 寫死版本號），不跑 `npm install`、`mvn`、cdxgen 這類建置工具。

**理由**：建置型解析（cdxgen、`npm install --package-lock-only` 等）本質上是**向套件索引（npm registry、PyPI、Maven Central 這類公開套件倉庫）查版本**，純離線的客戶原理上做不到。只有自建內部套件鏡像（Nexus、Artifactory）的客戶才可行，而且需要另一種容器——「可出網到套件索引白名單、又要執行客戶程式碼」，依 D1 合併原則必須另開——再加上網路層出網代理（egress proxy），等於把隔離環境再做六成。

| 客戶環境 | 建置型解析做不做得到 |
|---|---|
| 完全封閉網路 | **不行**：查不到任何套件索引 |
| 有內部套件鏡像 | **可以**：設定頁要填鏡像位址，客戶 IT 要開防火牆讓我們連鏡像 |
| 客戶提供已建好的環境 | **繞過**：直接掃建好的產物，但要工程師配合，與「顧問自給自足」的賣點衝突 |

**背景：業界三檔做法**

1. **純靜態**：讀 lock 檔與宣告檔，沒有 lock 檔就只能猜版本（Syft、Trivy）。
2. **執行建置工具取真實依賴樹**：準，但要出網、要執行客戶程式碼（cdxgen；商用產品如 Snyk、Black Duck 多走這條）。
3. **掃已建置的產物**：掃 `node_modules`、`.jar`、容器 image，最準，但要先有建好的東西。

第一版走 ①，並把「缺 lock 檔」這件事本身變成有價值的發現（見下）。

**缺 lock 檔怎麼處理（第一版必做，這是終局做法，不是過渡）**

- 掃完偵測到某生態系「有宣告檔、沒有 lock 檔」→ 結果頁顧問層與報表明確標示，例如：「此專案 npm 部分缺少 `package-lock.json`，弱點清單只涵蓋直接宣告的 N 個套件，版本為估算值，傳遞依賴（套件自己再用到的套件）看不到，實際風險可能更高。」
- SBOM 品質分扣分。
- 這件事本身列為一項**「供應鏈管理缺失」發現**，可對應 ISO 27001 A.8.28（安全程式設計）與 A.5.21（資通訊供應鏈的資訊安全管理）——專案連自己用了哪個版本都沒鎖定，本來就是管理缺失。
- 精靈頁 2 提示：「請工程師提供 lock 檔，或直接提供 SBOM 檔」，並連到 SBOM 上傳入口。

**問題**：Syft 支援的生態系很多，但我們只能對自己驗過的負責。

**選項**

- **A（採用）全收、但分「已驗」與「未驗」**：Syft 支援的都收；第一版實測並標「已驗」的是 Python（`poetry.lock`／`requirements.txt`／`Pipfile.lock`）、npm（`package-lock.json`／`yarn.lock`／`pnpm-lock.yaml`）、Java（`pom.xml` 固定版本、`gradle.lockfile`）；Go／.NET／Ruby／Rust 標「工具支援、未驗」。
- B 只收三種：其他語言直接拒絕，顧問會覺得「功能不能用」。
- C 全收不標示：出錯時沒有說法。

[**定案**：採 A。缺 lock 檔的處理照上方。]{.rec}

**為什麼**：顧問拿到的系統什麼語言都有，拒收不如照收並誠實標示可信度。
:::

::: {.callout .decided}
**D4 弱點資料怎麼更新（已定：獨立弱點資料更新包）**

**定案內容**：獨立的「弱點資料更新包」`guidant-vulndb-<日期>.tar`，附 sha256 manifest 與**簽章**，內含 Trivy DB、Grype DB、CISA KEV JSON（美國網路安全暨基礎設施安全局公布的「已知正被攻擊」弱點目錄）、EPSS CSV（FIRST 組織發布的「每個弱點未來 30 天被利用機率」估計）。存進 named volume `guidant-scan-vulndb`，沙箱唯讀掛入；掃描時 Trivy 帶 `--skip-db-update --offline-scan`、Grype 關閉自動更新。報表與結果頁標「弱點資料日期」，超過 30 天黃色警示、90 天紅色。

**封閉網路客戶**：安裝包隨帶一份；之後單獨下載更新包，用 `guidantai vulndb import <檔>` 匯入。

**連網客戶：不分兩套，只多一個取得管道**

- 系統設定頁按「檢查更新」，或設排程，從我們自家站台下載**同一份**更新包，自動走同一條匯入流程。
- **沙箱仍不出網**：下載由 api／worker 做，驗完簽章才寫進 volume；沙箱只讀 volume，網段照舊 internal。
- 更新包帶簽章，匯入前驗證，驗不過就拒收——與 FR-064 防竄改同一精神：客戶端只接受我們簽過的資料，被替換或改過的包進不來。封閉網路手動匯入時同樣驗。
- 匯入日期較舊的包會被拒，避免誤把資料倒退。

**被排除的做法**

- 讓 Trivy／Grype 在客戶機上直接線上更新：**排除**——資料版本不可控（同一天兩台客戶機可能拿到不同版本，報表無法重現），而且沙箱得開網，違反 D1 的信任邊界。
- 跟 agent 升級走：**排除**——agent 沒有這套機制，FR-039 也不做升級流程。
- 燒進 image：**排除**——Grype DB 約 3GB，每次升級都要重拉整顆 image，而且資料過期就得出新版產品。

**為什麼**：弱點資料的更新頻率（天）遠高於產品版本（月），兩者必須能分開更新；連網與封閉用同一份包，報表上的「弱點資料日期」在任何客戶機上意義都一樣。B 階段要實測：Trivy 掃 Java 是否另需 java-db、Trivy DB 實際大小（目前沒有文件）、簽章沿用哪一把金鑰。
:::

::: {.callout .decided}
**D5 工具組合（已定：Syft＋Trivy＋Grype，SBOM 同時支援 CycloneDX 與 SPDX）**

**已定事項：SBOM 同時支援 CycloneDX 與 SPDX**

Syft 一支工具產生，原生輸出 CycloneDX 與 SPDX 兩種格式；版本用 `syft convert` 轉（CycloneDX 1.4～1.7、SPDX 2.2／2.3）。精靈頁 3 的進階選項與結果頁原始層的下載選單，兩種格式一律同時列出。CycloneDX 是應用安全圈主流，SPDX 是法規與授權合規主流，美國政府採購兩者皆收；版本要可選是因為有些下游工具只認舊版（如國家資通安全研究院的 ex-sbom 只認 CycloneDX 1.6）。

**問題**：除了格式，產清單與查弱點各用哪些工具。

**四個 SBOM 產生器差在哪**

| 工具 | 看什麼列清單 | 強項 | 弱項 | 對我們的意義 |
|---|---|---|---|---|
| **Syft** | 純讀 lock 檔、套件中繼資料、image 分層 | 最普及；CycloneDX＋SPDX 原生多版本；不執行程式、不出網 | 沒有 lock 檔只能猜版本 | 沙箱的最佳選擇，出貨管線已在用 |
| **Trivy** | 同類讀法 | 一個指令同時出 SBOM 與弱點 | SBOM 不是主業、格式選項少、元件數偶爾較少 | 只用它查弱點、不產 SBOM，避免兩份清單打架 |
| **cdxgen** | 實際呼叫各語言的建置工具，取得真正的依賴樹 | 沒有 lock 檔也準、支援語言最廣 | 必須執行客戶的建置腳本，且要出網下載依賴 | 與沙箱「不執行、不出網」的邊界衝突，第二版再評估 |
| **osv-scalibr** | Google 的函式庫型核心 | 新、架構乾淨 | 不是成品；配套的 osv-scanner 對 Debian／Ubuntu 判不準 | 觀察 |

**結論**：只選 Syft 的用意，是在「純讀檔、不執行、不出網」這一類工具裡它做得最好——這正好就是沙箱的安全邊界——而且一支就能出兩種格式、多個版本。

**選項**

- **A（採用）Syft＋Trivy＋Grype**：與出貨管線相同、團隊已熟、白名單與別名解析可借。格式轉換用 `syft convert`，不另包 CycloneDX CLI。NTIA 最低要素七項（供應商、元件名、版本、唯一識別碼、依賴關係、SBOM 作者、產生時間）自己算，規則簡單。
- B 再加 sbomqs 算品質分：第二版。
- C 再加 cdxgen、osv-scanner：第二版（理由見上表）。

[**定案**：採 A。]{.rec}

**為什麼**：第一版的價值在呈現層，不在工具數量；工具組合與業界一致（見業界對照節），多加工具只會增加不一致要解釋。
:::

::: {.callout .decided}
**D6 結果怎麼呈現（已定：不做風險總評燈號，只給事實統計，判斷留給顧問）**

**理由**：任何我們自訂的燈號或分數，客戶只要問一句「評定標準是什麼」就站不住——「為什麼 3 個 High 是橙燈、不是 4 個」沒有權威答案。系統只呈現工具與公開權威來源的**事實**，不替任何人下結論；顧問拿事實去跟工程師對話，判斷是顧問的專業。

**顧問層改名「事實儀表板」，六個區塊**

1. **嚴重度統計**：Critical／High／Medium／Low 各幾個，Trivy 一組、Grype 一組並列。
2. **在 CISA KEV 目錄上的弱點**：逐筆列出（套件、版本、弱點編號、列入目錄日期）。
3. **EPSS 達門檻的弱點**：門檻可調，目前值顯示在區塊旁，預設 10%。
4. **可修未修**：已有修正版的弱點數量；其中最老的修正版發布於何時（「最早一個修正版 2023-03 就已發布」）。
5. **涵蓋完整度**：哪些生態系缺 lock 檔、NTIA 最低要素是否齊備（D3）。
6. **跟上次比**：修掉／新增／仍在。

**弱點清單的排序可選**（取代「最急的五個」）：預設依序 KEV → EPSS → CVSS，**目前的排序規則顯示在表頭**，顧問可以切換。排序是檢視方式，不是評等。

**報表只出事實句、不出結論句**

| 不寫 | 改寫 |
|---|---|
| 「本專案屬高風險」 | 「含 1 個 CISA KEV 目錄內弱點；9 個 High，其中 7 個已有修正版」 |
| 「建議立即處理」 | 「最早一個可用修正版發布於 2023-03」 |

**報表附「資料來源說明」一頁**：每個數字背後的來源各是誰維護——

| 來源 | 維護者 | 提供什麼 |
|---|---|---|
| NVD／CVSS | 美國國家標準暨技術研究院（NIST） | 弱點編號與嚴重度分數 |
| CISA KEV | 美國網路安全暨基礎設施安全局（CISA） | 已知正被攻擊的弱點目錄 |
| EPSS | FIRST（國際資安事件應變組織論壇） | 未來 30 天被利用機率估計 |
| 套件官方修正紀錄 | 各套件維護者（經 GitHub 安全公告等） | 哪一版修正、何時發布 |

**被排除的做法**

- A 分級規則表（紅橙黃綠）：**排除**——門檻（如「≥3 個 High」）答不出為什麼是 3。
- B 取 CVSS 最高分：**排除**——一顆 10 分但實際打不到的弱點會壓過全部，而且一樣是我們替客戶下結論。
- C 加權平均：**排除**——算得出數字，但沒人解釋得了為什麼是 7.3。

**要誠實標示的一點**：「有沒有公開攻擊程式」在離線環境沒有可靠資料源（exploit-db 這類資料未納入更新包），事實儀表板以 KEV 與 EPSS 呈現，不另做推估。
:::

::: {.callout .decided}
**D7 「每個數字追到原始輸出」怎麼呈現（已定：報表帶出處，不帶原始輸出）**

**報表每個弱點列標四件事**

- 哪個工具報的（Trivy、Grype，或兩家都報）
- CVE 編號，連到美國國家弱點資料庫（NVD）的公開頁面
- 工具的公告連結（advisory，例如 GitHub 安全公告頁）
- 修到哪一版（fixed version）

**附錄一頁**：工具名稱與版本、弱點資料庫日期、原始報告的 sha256 指紋與系統內下載位置；另附 D6 的「資料來源說明」一頁。

**不把原始輸出拉進報表**。原始報告在系統的原始層可以下載，指紋讓「這份就是當時那份」可以被驗證，工程師無法說報表是編的。

**被排除的做法**

- 整份原始 JSON 附在報表後面：**排除**——動輒幾十到上百頁，顧問看不懂，客戶也不會翻。
- 每個數字都做成連回系統的超連結：**排除**——報表常以 PDF 離線傳遞，客戶點不開系統連結。
:::

::: {.callout .decided}
**D8 能力點、角色與授權照（已定：歸既有「檢測工具包」，不另開加購）**

> 這就是檢測工具的一種，未來要不要拆開老闆會跟市場部討論，產品還沒上市。
> [— 決策者原話（2026-10-10）]{.attr}

**授權**：歸屬既有的「檢測工具包」（FR-062 四個加購包之一，含 `remote-agent-manage`、`detection-profile`、`plugin`），不另開加購項。沒買檢測工具包的租戶，側選單不出現「原始碼掃描」，檢測工具清單也不列「SBOM 弱點掃描」。日後若市場決定拆賣，只改簽發站 plan、不改程式——能力點本來就落在 `detection` 模組內，符合「加值包整包落在單一模組」的硬規則。

**能力點**：`detection.source-scan.{read,create,update,delete,execute,export}`（execute 是「觸發掃描」，export 是「匯出報表」，都在允許的額外動詞內）。弱點資料更新與資源上限的設定只給管理員——要開 `detection.source-scan-setting.*` 還是併入系統設定頁，design 階段定。

**出廠角色**

| 角色 | 權限 |
|---|---|
| 系統管理員 | 全部 |
| 稽核主管 | read／create／execute／export |
| 稽核員 | read／create／execute／export |
| 唯讀 | read |

產品沒有「顧問」角色，顧問對應系統管理員或稽核主管。

**被排除的做法**

- 獨立加購：**排除**——產品還沒上市，不先拆。
- 放進基礎包：**排除**——它屬於檢測工具這一類，跟其他檢測工具同一個販售單位。
:::

::: {.callout .decided}
**D9 獨立模組還是檢測工具（已定：一個核心、兩個入口，同版交付）**

**問題**：要做成獨立功能，還是做成檢測工具之一？

**兩個入口同一版交付**（階段 D 與 H）。下面說明的是「為什麼共用一個核心」與「入口 2 怎麼接進既有檢測工具鏈」。

```{.mermaid cap="圖 5 — 一個核心、兩個入口：入口 2 是檢測工具清單裡的一個工具，在伺服器端執行、不派 Agent"}
%%{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
  E1["入口 1：獨立模組<br/>側選單・掃描對象・歷次比對"] --> CORE["核心引擎<br/>輸入→沙箱跑工具→正規化→報表<br/>不知道被誰叫"]
  E2["入口 2：檢測工具「SBOM 弱點掃描」<br/>建任務可選→任務頁執行<br/>execution_target=server"] -->|"background_jobs"| CORE
  CORE --> T1[("source_scans 系列表")]
  CORE -->|"DetectionResultHandler"| T2[("detection_executions<br/>job_evidences（轉證據）")]
```

**採用：一個核心、兩個入口**

- **核心引擎**：輸入 → 沙箱跑工具 → 正規化 → 報表，不知道是被誰叫的。
- **入口 1 獨立模組**：側選單、租戶層掃描對象清單、歷次比對、顧問報表。
- **入口 2 檢測工具**：檢測工具清單裡多一個工具「SBOM 弱點掃描」，與 OpenSCAP、SonarQube 並列，**整條鏈都在**：建任務可選 → 任務頁執行 → 結果進檢測紀錄 → 轉證據。
- **核心契約約束（兩入口共用）**：① 核心的輸入輸出形狀對齊 `detection_executions`（`summary` JSONB＋`report_file_id`＋可重跑）；② 能力點放 `detection` 模組（D8）；③ 掃描表的對象 uid 可空，檢測工具入口不建對象直接跑。核心不得出現「判斷是哪個入口叫的」分支——入口差異只在呼叫端與結果落點。

**入口 2 怎麼接（已定：不派 Agent，使用者不必選執行位置）**

現況：FR-056 的檢測工具是「資料庫一列＋Agent 上的執行器」，一律派給遠端 Agent（座標見現況盤點）。本案新增一條**伺服器端執行路徑**，以後任何「不需要連客戶主機」的工具都走這條。

| 環節 | 做法 |
|---|---|
| 工具目錄 | `config.detection_tools` 加欄位 `execution_target`（`agent`｜`server`，預設 `agent`，既有列不動）。seed 一列 `code='sbom-scan'`、`execution_target='server'`、`requires_target_host=false`、`requires_credentials=false`、不設 `connection_type` |
| 建任務 | 使用者只是從工具下拉選「SBOM 弱點掃描」。`AoTaskForm` 依 `execution_target` 隱藏 Agent 指派區塊，**不會出現 Agent 指派與目標主機欄位** |
| 參數 | schema 一份：`scan_mode`（`upload`｜`sbom`，沿用 SonarQube 的上傳機制，不新造檔案欄位型別）、`sbom_formats`（多選）、`tools`（預設三支） |
| 派工 | `start_execution` 看到 `execution_target=='server'` 就不走 `_dispatch_one`／`_pick_agent`，改入列 `background_jobs`（job_type `sbom_scan`），由 worker 送沙箱；`detection_executions` 照建 running 列，`agent_task_uid` 留空。新開的分岔檔**依工具 `code` 查 job handler 登記表**（`{'sbom-scan': handler}`），不把 SBOM 寫死在分岔內；日後新增伺服器端工具只需登記一筆 |
| 結果 | worker 跑完呼叫同一支 `DetectionResultHandler.handle_result`（報表、SBOM、原始 JSON 當多檔結果），既有轉證據流程零改動；同時 `source_scans` 存完整結果（`target_uid` 空、`detection_execution_uid` 對應） |
| 任務頁抽屜 | 既有執行鈕、執行列表、summary 中文對照共用；summary 對照表加 SBOM 六區塊的 key；「看完整結果」連到獨立模組的結果頁（同一筆 `source_scans`） |

**被排除的做法**

- 只做成檢測工具：**排除**——顧問沒有稽核任務時也要能用；顧問 C 的三個月回訪情境跨專案，歷次比對要掛在「對象」上，不是掛在任務上。
- 兩個入口各自實作：**排除**——兩套掃描與正規化邏輯，違反「先查再寫、不重複造輪子」。
- 做成 Agent 上的執行器：**排除**——Agent 要帶三支工具加約 3GB 弱點資料，而且每台都要各自更新，違反 D4；Agent 的定位是「連客戶主機」，掃上傳檔不需要連任何主機。

**為什麼**：兩種用法都要有：顧問在稽核任務外也要能掃、要跨專案比對（入口 1）；稽核員在任務裡要能把結果直接變成證據（入口 2）。共用一個核心，兩個入口算出來的數字才會一致，也只有一套掃描與正規化邏輯要維護。
:::

::: {.callout .decided}
**D12 資源上限與時限（已定：照下表，全部可設定）**

| 項目 | 定案值 |
|---|---|
| 沙箱（拆檔與掃描共用） | 記憶體 4g（現行 2g）、cpus 2 |
| 單次掃描時限 | 20 分鐘 |
| 源碼包上傳 | 100MB；解壓後 500MB、5 萬檔（沿用 FR-058.7 限額） |
| SBOM 檔上傳 | 50MB |
| 失敗重跑 | 不自動重跑（`max_attempts=1`），顧問手動按重掃 |

以上全部可在設定頁調整，表中是出廠預設。

**為什麼**：Grype 比對大型 SBOM 時吃記憶體，2g 不夠；20 分鐘涵蓋草稿情境中「568 個 npm 套件」量級還有餘裕；調高的上限證據分類一併受益。不自動重跑是因為重跑成本高、而且同一份輸入結果可重現，失敗多半要顧問換檔或調限額。C 階段要用實際大型源碼包驗證，並量測掃描期間證據分類的排隊時間（D1 的取捨）。
:::

::: {.callout .decided}
**D13 FR-058.7 上傳分支能複用到哪裡（已定：只借檢查器，不借流程）**

- **複用**：`detection_source_file` 的限額與副檔名白名單、`ProfileArchiveValidator`（zip-slip、符號連結、檔頭）、`ArchiveBombGuard`（壓縮炸彈）。
- **新寫**：一支「安全解壓到磁碟」放 jedi-detection 共用層，照 `.claude/rules/security/uploads-parsing.md` 四道（禁 `extractall`、每個項目做 realpath 檢查、邊解邊計量、外部工具報告視為外部輸入），在沙箱內執行。
- **不接** SonarQube 的整條流程（orchestration）。

**被排除**：整條沿用 FR-058.7——那條的解壓在 agent 端，而且綁 SonarQube 的流程。

**為什麼**：檢查器是通用的；解壓位置與後續流程不同。安全解壓放在共用層，兩個入口與未來其他功能都能用。
:::

::: {.callout .decided}
**D14 AI 分析（已定：有，但使用者主動按才跑）**

- 報告頁一顆「AI 分析」按鈕，**按了才呼叫、才扣額度**，不自動生成。
- 結果標示「AI 生成，以上方統計為準」。
- **直接借 FR-122 AI 閘道**：服務商設定、額度、呼叫紀錄、提示注入防護都現成，本案只加一個提示樣板（prompt template），不建新的基礎設施。
- 與 D6 不衝突：系統預設只給事實；分析是使用者自己要的，而且明確標示是 AI 產出。

**被排除的做法**

- 每次掃描自動生成：**排除**——花額度，而且等於系統預設替使用者下結論，違反 D6。
- 第一版不做：**排除**——使用者確實需要一段白話整理，閘道又是現成的，成本只有一個提示樣板。
:::

---

## 風險與陷阱 {#risks nav="風險"}

::: {.callout .crit}
**🔴 派工服務已嚴重超標，伺服器端分岔必須新開檔**

`detection_orchestration_service.py` 已 2,500 多行，遠超檔案 800 行上限。本地執行分岔**必須新開檔**（例如 `server_side_dispatch.py`），再從 `start_execution` 一行呼叫，不准往原檔裡加邏輯（CLAUDE.md 體積規則）。reviewer 看到往原檔塞會列為阻斷級。分岔檔依工具 `code` 查 job handler 登記表（`{'sbom-scan': handler}`），不把 SBOM 寫死在分岔內——這是給日後伺服器端自動化任務留的地基（見「未來項」節）。
:::

::: {.callout .crit}
**🔴 守衛測試寫死四個容器名，新容器不會被檢查**

`test_security_shape_container_hardening.py:17` 的清單是寫死的，而且不守唯讀與 internal 網段。本案雖然不加容器，但沙箱改吃樣板時若樣板漏了唯讀，不會有任何測試紅；第二版加抓檔容器時更是如此。A 階段必須先改守衛（按 `-sandbox` 結尾自動納入、補唯讀與 internal 斷言），再動 compose。
:::

::: {.callout .warn}
**沒有 lock 檔的掃描結果會「看起來很乾淨」**

Syft 讀不到確切版本、也看不到傳遞依賴時，比對弱點會少很多，報表上的弱點數可能很少，看起來很乾淨。顧問最容易被這個誤導。所以 D3 規定：缺 lock 檔的標示、品質扣分與「供應鏈管理缺失」發現都必須放在顧問層顯眼處，不能只在原始層；**涵蓋不完整要在每一組統計旁同時顯示**，數字不可單獨出現。
:::

::: {.callout .warn}
**拆檔與掃描共用沙箱，掃描會讓證據分類排隊**

D1 已接受的取捨。大型源碼包掃描最長 20 分鐘，這段時間證據分類要等。上線前要量測排隊時間；手冊要寫明「掃描進行中，證據分類可能變慢」。
:::

::: {.callout .warn}
**沙箱 image 變大，出貨弱點掃描範圍跟著變**

三支工具裝進沙箱 image 後，出貨管線（`sbom.sh --images`）掃這顆 image 時也會掃到工具本身的依賴。工具若有 Critical 弱點，會擋住出包——A 階段要先跑一次出貨弱點判定，確認白名單是否要調。
:::

::: {.callout .warn}
**兩家工具意見不一致是常態**

Trivy 與 Grype 的弱點來源、比對規則、分數來源都不同。結果頁要用白話說明「為什麼兩家說法不同」，不能丟兩個數字讓顧問自己猜。
:::

::: {.callout .warn}
**弱點資料過期，結果就失真**

封閉網路客戶可能半年不更新。資料日期必須出現在結果頁與每一份報表上，過期警示不能關。
:::

::: {.callout .warn}
**外來 SBOM 檔是不受信任的輸入**

工程師交來的 SBOM 可能格式錯誤、超大、或刻意構造來打解析器。一律在沙箱內解析，後端行程不碰內容，大小 50MB 封頂。
:::

---

## 未來項（第二版 Git 來源） {#future nav="未來項"}

第一版只收上傳。下面三題是 Git 來源的設計，討論時已有建議，原文保留供第二版接續；**第一版不實作、不需裁決**。第二版要做時，依 D1 的合併原則，Git 抓檔容器「要出網」與既有沙箱信任邊界不同，會另開一顆 `guidant-scan-fetcher`，套同一份加固樣板，出網政策填 `git-allowlist`；資料模型只在 `source_type` 加 `git`、細節放 `source_ref`，不改表。另需兩張設定表：`config.source_scan_git_hosts`（Git 站台白名單：`host`、`label`）與 `config.source_scan_credentials`（租戶憑證：`name`、`git_host`、`username`、`secret_params` envelope、`last_used_at`），共同欄位與 RLS 同資料模型節。

::: {.callout .warn}
**未來：抓檔容器出網的範圍怎麼管（原 D2）**

**問題**：抓 Git 一定要出網，但不能讓它變成「打客戶內網的跳板」。

**選項**

- **A（建議）兩層**：① 應用層——後端接單時驗 repo 位址：只准 `https`、站台必須在租戶管理員維護的白名單、DNS 解析結果不得是內網位址（防伺服器端請求偽造，即借我們的伺服器去打客戶內網）；② 容器層——git 設 `protocol.allow=https`、`core.hooksPath=/dev/null`（不跑 repo 內的掛勾程式）、不抓子模組、`--depth 1`。
- B 再加網路層白名單（egress proxy，所有出網都經代理、只放白名單站台）：最嚴，但多一顆容器與一套設定，第一版列未來項。
- C 直接在 worker 裡 clone：**排除**——worker 有金鑰與 DB 連線，被惡意 repo 攻破代價太大。
- D 沙箱自己出網抓檔：**排除**——違反 FR-122「拆檔與出網不同容器」，也違反 D1 的合併規則（能否出網不同就不能合併）。

[**建議**：採 A，B 列未來項。]{.rec}

**為什麼**：A 擋掉絕大部分情境且成本低。**已接受風險要寫明**：抓檔容器若被攻破（例如 git 本身的漏洞），它可以出網；但它手上沒有工具、沒有弱點資料、沒有 DB 連線、沒有長存金鑰，能拿到的只有當次那一份 repo。
:::

::: {.callout .warn}
**未來：Git 來源要不要先登記才能掃（原 D10，類比 FR-129 目標授權）**

**問題**：FR-129 規定偵測目標必須來自登記資料。Git 位址要不要比照「逐個 repo 登記」？

**選項**

- **A（建議）站台白名單**：沿用上一題的 Git 站台白名單，由租戶管理員維護，形狀類似 FR-129 的掃描網段；站台內的任何 repo 都可掃。
- B 逐 repo 登記：最嚴，但顧問每換一個 repo 都要找管理員，違背「自給自足」。列第二版選項。

[**建議**：採 A，B 列第二版。]{.rec}

**為什麼**：風險的核心是「伺服器會連去哪裡」，站台層白名單已經管住這件事；repo 層的差異只是同一站台內的哪個倉庫。
:::

::: {.callout .warn}
**未來：私有倉庫憑證怎麼存、怎麼用（原 D11）**

**問題**：顧問填的 Git token 是客戶的祕密。

**建議做法**

- 存 `detection_secret_params` 的 envelope 寫法（Fernet 加密）。
- **金鑰：復用 `DETECTION_TOOL_ENCRYPTION_KEY`**（同屬偵測域，不新增環境變數）。被排除：另開新金鑰——多一把要保管、要輪替，換不到額外防護。
- job payload 只放 `credential_uid`。
- worker 解密後經內網請求本文交給抓檔容器（不進網址、不進命令列參數）；抓檔容器以子行程環境變數餵 `GIT_ASKPASS` 小腳本，**不落磁碟**，clone 完即丟。
- 精靈提供兩個選項：「只用這一次（不儲存）」與「存為租戶憑證」。讀取 API 只回「已設定」。

[**建議**：照上述做法。]{.rec}

**為什麼**：完全照 `.claude/rules/security/secrets.md` 的規則（payload 只放 uid、明文不進磁碟、不進子行程參數、讀取只回有無）；「只用這一次」讓不想把客戶帳密留在系統的顧問有選擇。
:::

::: {.callout .warn}
**未來：伺服器端自動化任務的通用機制（大整理時處理）**

決策者方向：顧問很多自己做的流程——產掃描報告、帳號清查後匯出清冊直接列入證據等——未來都要包進任務內，不只檢測工具。**本案不做通用化**，先把 SBOM 做出來，之後一次大整理。

大整理時要處理：

- 工具目錄加分類（檢測／自動化作業）
- 授權按工具列判，而不是按任務型態
- summary 的中文對照由工具列自帶，而不是寫死在 Vue 元件裡
- 任務型態的顯示名稱

本案只留一個地基：伺服器端分岔檔依工具 `code` 查 job handler 登記表（D9「派工」列），日後新增伺服器端工具只需登記一筆，不必再改分岔。
:::

::: {.callout .warn}
**未來：抓檔容器可出網（已接受風險）**

Git 抓檔容器被攻破時（例如 git 本身的漏洞），它可以出網。緩解靠它手上什麼都沒有：沒有掃描工具、沒有弱點資料、沒有 DB 連線、沒有長存金鑰，能拿到的只有當次那一份 repo。網路層出網白名單（egress proxy）隨第二版一起評估。
:::

::: {.callout .warn}
**未來：Git 位址可能被拿來打客戶內網**

若只檢查站台名稱、不檢查 DNS 解析結果，顧問（或拿到顧問帳號的人）可以把一個看似正常的站台名稱指到內網位址，讓伺服器替他連進去。接單時必須驗 DNS 結果不得是內網位址，clone 前再驗一次（防止兩次解析結果不同）。
:::

::: {.callout .warn}
**未來：私有倉庫憑證是客戶的祕密**

明文只能出現在 worker 與抓檔容器的單次請求記憶體裡，不進網址、不進命令列參數、不落磁碟、不進 log。「只用這一次」選項必須真的不寫進任何表。
:::

---

## 不在範圍 {#out-of-scope nav="不在範圍"}

- 動態掃描（DAST，對執行中的系統發動測試）、滲透測試
- 修弱點：只報告，不改客戶程式碼
- 取代我們自己的出貨管線：`sbom.sh` 維持不動；產品功能成熟後，日後可反過來讓出貨管線吃產品的服務，但不是這次
- 把 ex-sbom 或 Dependency-Track 整包嵌進產品
- 建置型依賴解析（執行客戶建置工具，cdxgen 那條路）——需客戶有套件鏡像，列未來項（見 D3、D5）
- Git 來源（含私有倉庫憑證、Git 站台白名單、抓檔容器）——第二版，見「未來項」節
- 網路層出網白名單（egress proxy）——隨 Git 來源一起評估
- 逐 repo 登記——第二版，見「未來項」節
- AI 自動生成分析：只做使用者主動按的版本（見 D14）
- 掃描客戶的容器 image（Syft 支援，但第一版只做源碼壓縮檔與 SBOM 檔）
- 定期自動重掃與弱點告警通知
- 授權合規的法律意見：只標出傳染性授權等風險，不判定是否違約

---

## 與其他需求的關係 {#relations nav="關聯"}

| FR | 關係 |
|---|---|
| [FR-122](../FR-122-2609-ai-gateway-and-sandbox/) | 沙箱的加固形狀與「拆檔與出網不同容器」原則的來源；本案把掃描工具併進該沙箱，加固設定抽成樣板 |
| [FR-127](../FR-127-2610-gitlab-ci-release/)、[FR-128](../FR-128-2610-image-zero-critical/) | 出貨 SBOM 與雙工具弱點判定管線；指令組合與別名解析從這裡搬 |
| [FR-058](../FR-058-2607-detection-tools-expansion/) | 源碼上傳掃描的限額與壓縮檔檢查器（D13） |
| FR-056 | 檢測工具目錄、派工、`detection_executions` 與轉證據流程；入口 2（階段 H）在這條鏈上多一個工具，並新增伺服器端執行路徑（D9） |
| FR-129 | 目標必須來自登記資料的原則；第二版 Git 站台白名單對應（未來項） |
| [FR-130](../FR-130-2610-internal-connection-tls/) | 內部 CA 掛載慣例，第二版抓檔容器信任客戶自簽 Git 站台時可借 |
| [FR-065](../FR-065-2608-onprem-installer/) | 落地版離線前提；弱點資料更新包走安裝包與 `guidantai` 指令 |
| [FR-132](../FR-132-2610-access-management/) | 能力點命名與「加值包整包落在單一模組」硬規則（D8） |
| [FR-122](../FR-122-2609-ai-gateway-and-sandbox/) 的 AI 閘道 | D14 的 AI 分析按鈕直接借用 |
