---
title: "原始碼弱點掃描與 SBOM 報表 — 設計文件 (FR-134)"
brand: "Guidant AI · **FR-134** 原始碼弱點掃描與 SBOM 報表"
eyebrow: "FR-134 · 設計文件 v1 · 2026-10-10（D1–D14 全數定案）"
h1: "原始碼弱點掃描與 SBOM 報表 — 設計定案"
subtitle: "上傳源碼包或 SBOM 檔，既有沙箱跑 Syft／Trivy／Grype，事實統計不下結論，兩個入口同版交付"
lede: "顧問上傳客戶的原始碼壓縮檔或現成的 SBOM，系統在既有沙箱裡產物料清單、雙工具比對弱點、算出六區塊事實統計，匯出 PDF／HTML／DOCX；三個月後再掃一次，修掉幾個、新增幾個一眼看到。**不替任何人下結論**，每個數字都標工具與出處。同一個核心兩個入口：側選單獨立模組，以及檢測工具清單裡的「SBOM 弱點掃描」（建任務可選、不派 Agent、結果轉證據）。本文件把 14 項決策展開到「照做就能交」的程度，拆成八個子需求、30 張實作子任務。"
chips: [
  {text: "D1–D14 全數定案", kind: ok},
  {text: "30 張子任務", kind: accent},
  {text: "跨 3 repo：BE／FE／jedi-python-package", kind: accent},
  {text: "容器數不變：工具併進既有沙箱", kind: ok},
  {text: "只收上傳；Git 來源列第二版、畫面不出現", kind: ok},
  {text: "陷阱：三支工具進沙箱 image 會被出貨弱點閘掃到", kind: crit},
  {text: "陷阱：派工服務檔 2,571 行，分岔必須新開檔", kind: crit}
]
footer: "FR-134 · 原始碼弱點掃描與 SBOM 報表 — 設計文件 · 2026-10-10 · 討論稿：[discussion.html](discussion.html)（現況盤點、業界對照、選項推演）· 白話需求：[draft-requirement.md](draft-requirement.md) · 沿革見 LOG 與 git log"
---

> 狀態：**設計定案（D1–D14 全數拍板）**｜日期：2026-10-10｜前作：FR-122（沙箱與 AI 閘道）、FR-056／058.7（檢測工具與源碼上傳）、FR-127／128（出貨 SBOM 管線）
> 討論稿（現況盤點座標、業界對照、各選項推演）：[`discussion.html`](./discussion.html)

> 🔴 **這份是設計決策，不是現況。** 內文的行號與座標停在 2026-10-10 定稿當時，之後實作改動的結果不回頭改寫這裡——上線後的現況看 SPEC 頁。

## 變更紀錄 {#changelog nav="變更紀錄"}

| 日期 | 變更 | 對應 |
|---|---|---|
| 2026-10-10 | 初版設計定案（D1–D14；D2／D10／D11 移未來項），拆 8 子需求 30 張子任務 | FR-134 母案 CM-2625，子卡 CM-2626～CM-2655 |

---

## 1. 需求背景與端到端流程 {#background nav="背景"}

顧問輔導客戶時，專案的第三方套件弱點本該由客戶工程師盤點回報，實務上常被「那個不嚴重」「我們環境打不到」帶過，顧問沒有技術背景無從判斷。本案讓顧問**不靠工程師**就能拿到一份有工具原始輸出背書的弱點報表。

產品現況：出貨管線已有整條「Syft 產 SBOM → Trivy＋Grype 雙掃 → 白名單判定」（`scripts/build/sbom.sh`、`vuln_gate.py`），但只服務我們自己的出貨，客戶看不到。缺的是：

1. **沙箱裡沒有掃描工具**：`guidant-sandbox` 只會拆檔，沒有 Syft／Trivy／Grype，呼叫介面只有 `/v1/extract`。
2. **弱點資料無法離線**：build 機每次掃描都線上更新，沒有任何匯出、釘版、隨包帶的機制；落地版客戶裝好弱點永遠是零。
3. **沒有「給非技術人看」的那一層**：市面工具全是 CVE 清單與分數，沒有事實統計、歷次比對、可交付報表。
4. **檢測工具一律派 Agent**：FR-056 的工具是「資料庫一列＋Agent 執行器」，沒有「伺服器自己跑」這條路。

### 端到端

```{.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'}}}%%
sequenceDiagram
    participant U as 顧問（瀏覽器）
    participant API as guidant-api
    participant S as SeaweedFS
    participant W as guidant-worker
    participant SB as guidant-sandbox
    participant V as vulndb volume
    participant DB as guidant-db
    U->>API: 上傳源碼包或 SBOM 檔
    API->>S: 存原檔
    API->>DB: 建 source_scans（queued）＋入列 background_jobs
    W->>DB: 撿單（SKIP LOCKED）
    W->>S: 取原檔到工作目錄
    W->>SB: POST /v1/scan（檔案、選項）
    SB->>SB: 安全解壓 → syft → trivy／grype（離線）
    SB->>V: 唯讀讀取弱點資料
    SB-->>W: SBOM 多格式＋兩家原始 JSON
    W->>W: 正規化：配對、KEV／EPSS、六區塊統計、NTIA
    W->>S: 存 SBOM、原始 JSON（算 sha256）
    W->>DB: 寫 components／findings／summary（done）
    U->>API: 看結果、匯出報表
```

執行期四段白話進度（接收檔案 → 產出物料清單 → 比對弱點 → 整理報告）由 worker 經 `ctx.report_progress` 回報，清單頁輪詢顯示。檢測工具入口（§5.5）走同一條：`start_execution` 看到伺服器端工具就入列同一種背景工作，跑完多一步呼叫 `DetectionResultHandler` 把報表轉成任務證據。

---

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

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

給非工程讀者的背景：現代程式九成以上是拼裝現成的第三方套件。套件被發現有安全漏洞時，用到它的程式就跟著有風險。**SBOM**（軟體物料清單）是一份「這支程式用了哪些套件、哪個版本」的清單；拿這份清單去比對公開的弱點資料庫，就知道哪裡有洞、多嚴重、有沒有修正版。

| 子需求 | 做什麼 | 產出 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|
| **A 沙箱擴充** | 既有沙箱 image 加裝三支工具與掃描端點；弱點資料區；加固設定抽成共用樣板；守衛測試改自動納入 | 沙箱 image 新版、compose 樣板、守衛測試 | 進沙箱 `syft version`、`trivy --version`、`grype version` 三支都有；沙箱內 `curl https://github.com` 失敗；守衛測試全綠，且把沙箱 `read_only` 拿掉會紅；證據分類照常可用 |
| **B 弱點資料更新** | build 機產更新包（Trivy DB、Grype DB、OSV 離線庫、KEV、EPSS）帶簽章；安裝包隨帶一份、裝機自動放入；設定頁「上傳更新包」與連網「檢查更新」；每次更新留紀錄 | 更新包產製腳本、設定頁區塊、更新紀錄表 | 拔網路線的機器裝完，設定頁顯示「弱點資料日期」；掃一個已知有洞的範例專案弱點數非零；在設定頁上傳新包後日期前進、紀錄多一列寫誰何時；改過一個位元組的包會被拒 |
| **C 掃描後端** | 資料表、背景工作、兩種輸入路徑、安全解壓、三家輸出正規化、SBOM 多格式輸出、能力點與授權、API | migration、job handler、正規化層、API | 用 API 送一個源碼包、一份 SBOM 檔，兩筆都跑完，查得到元件數、弱點數、六區塊統計；唯讀角色打寫入 API 回 403 |
| **D 前端** | 掃描對象清單、新增精靈三頁、四段進度、結果頁三層分頁、設定頁 | 前端頁面 | 不看文件照畫面新增一筆，等進度走完四段，結果頁看得到六區塊統計、排序可切換且表頭顯示規則；唯讀角色看不到「新增」；畫面與競品無可辨識的版面雷同 |
| **E 報表與 AI 分析** | 三種樣式 × PDF／HTML／DOCX；弱點列標工具、CVE、公告連結、修正版；附錄與資料來源頁；「AI 分析」按鈕 | 報表產生器、提示樣板 | 九種組合各匯出一份，中文不缺字；報表裡沒有任何結論句；任挑一個數字在附錄找得到它來自哪支工具；按「AI 分析」才呼叫，結果標示「AI 生成」 |
| **F 歷次比對** | 同一對象兩次掃描比：修掉／新增／仍在；回訪報告 | 比對頁、回訪報告 | 升一個套件版本再掃，「修掉」欄出現該弱點；換一個仍受影響的版本，該弱點留在「仍在」 |
| **H 檢測工具入口** | 工具目錄加「伺服器端執行」、seed「SBOM 弱點掃描」；建任務可選且不出現 Agent 欄；派工分岔；結果轉證據 | 工具目錄列、派工分岔檔、抽屜對照 | 專案規劃建檢測工具任務時下拉能選「SBOM 弱點掃描」且無 Agent 指派欄；不需任何 Agent 在線，執行列表出現一筆並跑完；結果檔出現在任務證據清單；「看完整結果」進獨立模組數字一致 |
| **G 收尾** | SPEC 頁、使用手冊、安裝文件、資安規則檔補路徑 | 文件 | 手冊找得到「怎麼新增掃描、怎麼更新弱點資料、數字怎麼看」三件事 |

依賴：A → B、C 平行 → D、E、H 平行 → F（要 C＋D）→ G。B 未完成前 C 可在 DEV 用線上資料開發。

---

## 3. 決策定案（D1–D14） {#decisions nav="決策定案"}

| # | 決策 | 定案 | 理由 | 被排除的方案 |
|---|---|---|---|---|
| **D1** | 沙箱怎麼擴充 | Syft／Trivy／Grype **併進既有 `guidant-sandbox` image**，容器數不變；新增 named volume `guidant-scan-vulndb` 唯讀掛入；加固設定抽成 compose x-anchor `x-guidant-sandbox-hardening`（參數：image、網段、volume、預留「出網政策」）；守衛測試改「名字以 `-sandbox` 結尾自動納入」並補唯讀與 internal 斷言；記憶體 2g→4g。**合併原則**：容器只按「能否出網、碰不碰客戶輸入、有無金鑰」三個信任邊界分，答案相同就合併 | 決策者點名容器數顧慮；拆檔與掃描三個邊界答案完全相同，多一顆換不到隔離 | 另開掃描沙箱（容器膨脹）；每容器各寫加固設定（會漏且守衛抓不到）。**已接受取捨**：大源碼包掃描期間證據分類排隊，靠 `--max-concurrent` 與佇列緩解 |
| **D2** | 出網範圍 | **移未來項**（Git 來源第二版） | 第一版只收上傳，沙箱不出網 | — |
| **D3** | 生態系與解析方式 | **只做純靜態解析**（讀 lock 檔與釘死版本的宣告檔），不執行客戶建置工具。Syft 支援的都收，Python／npm／Java 標「已驗」，其餘標「工具支援、未驗」。**缺 lock 檔**：結果頁與報表明確標示涵蓋不完整、品質分扣分、列為「供應鏈管理缺失」發現（ISO 27001 A.8.28／A.5.21）、精靈提示改用 SBOM 上傳。這是終局做法不是過渡 | 建置型解析要向套件索引查版本，純離線客戶原理上做不到；要做還得另一種出網容器＋代理 | 執行建置工具（cdxgen 等）；只收有 lock 的專案 |
| **D4** | 弱點資料更新 | 獨立「弱點資料更新包」（Trivy DB、Grype DB、OSV 離線庫、CISA KEV、EPSS＋sha256 manifest＋簽章）。進入途徑三條：①裝機／升級時安裝程式自動放進 volume；②設定頁**「上傳更新包」**；③連網客戶「檢查更新」／排程從自家站台抓。**沙箱仍不出網**，下載與寫入由 api／worker 做。每次更新**留紀錄**（誰、時間、前後日期、來源、結果）。資料日期超 30 天黃、90 天紅。**不做 CLI 匯入指令** | 資料更新頻率（天）遠高於產品版本（月）；客戶不用 console；三條途徑同一份包、同一條驗簽寫入 | 讓工具在客戶機線上更新（版本不可控、沙箱得開網）；燒進 image（3GB 每次重拉）；跟 Agent 升級走（無此機制）；CLI 指令（客戶不用） |
| **D5** | 工具組合與格式 | Syft 產 SBOM，**CycloneDX 與 SPDX 同時支援**（原生輸出，版本用 `syft convert` 轉：CycloneDX 1.4–1.7、SPDX 2.2／2.3）；Trivy＋Grype＋OSV-Scanner 三家各自比對弱點（使用者可勾選，預設全勾；結果並列不取平均，畫面與報表只長實際跑了的工具欄）；OSV 只給 CVSS 向量，分數與等級由 PyPI `cvss` 套件從向量算；NTIA 七要素自算 | Trivy／Grype 與出貨管線一致、團隊已熟；OSV 補 Google 維護的跨生態系資料源，三家交叉對照；Syft 是「純讀檔不執行不出網」裡做得最好的 | cdxgen／sbomqs（第二版）；Trivy 兼產 SBOM（兩份清單打架）；OSV 分數留空（無法與另兩家並排比較） |
| **D6** | 風險總評 | **不做燈號或分數，只給事實統計，判斷留給顧問**。顧問層＝「事實儀表板」六區塊：嚴重度統計（兩家並列）、KEV 目錄內弱點、EPSS 達門檻弱點（門檻可調預設 10%）、可修未修、涵蓋完整度、跟上次比。排序可選（預設 KEV→EPSS→CVSS，規則顯示在表頭）。報表只出事實句，附「資料來源說明」頁 | 任何自訂燈號客戶問「標準是什麼」就站不住；系統只呈現工具與公開權威來源的事實 | 分級規則表（門檻答不出為什麼）；CVSS 最高值；加權平均 |
| **D7** | 數字追溯 | 報表每個弱點列標：哪個工具報的、CVE 編號（連 NVD）、工具公告連結、修到哪版；附錄一頁（工具與版本、弱點資料日期、原始報告 sha256 與系統下載位置）。**不把原始輸出拉進報表** | 指紋讓「這份就是當時那份」可驗證；原始 JSON 幾十到上百頁沒人看 | 整份原始 JSON 附報表；每數字超連結回系統（離線 PDF 點不開） |
| **D8** | 能力點、角色、授權 | 授權歸既有**「檢測工具包」**（FR-062 四加購包之一），不另開；能力點 `detection.source-scan.{read,create,update,delete,execute,export}`；弱點資料設定併入系統設定頁、只給管理員；出廠角色：稽核主管／稽核員 read／create／execute／export，唯讀 read | 決策者原話「這就是檢測工具的一種，未來要不要拆開老闆會跟市場部討論，產品還沒上市」 | 獨立加購；基礎包 |
| **D9** | 獨立模組還是檢測工具 | **一個核心、兩個入口，同版交付**。核心：輸入→沙箱→正規化→報表，不知道被誰叫。入口 1 獨立模組（側選單、掃描對象、歷次比對）；入口 2 檢測工具清單裡的「SBOM 弱點掃描」，與 OpenSCAP／SonarQube 並列，整條鏈（建任務可選→任務頁執行→結果進檢測紀錄→轉證據）都在，**在伺服器端執行、不派 Agent、使用者不必選執行位置**。核心契約：輸出對齊 `detection_executions.summary`；掃描表對象 uid 可空；核心不得判斷是哪個入口 | 顧問在任務外也要能掃、要跨專案比對；稽核員在任務裡要能直接變證據；共用核心數字才一致 | 只做檢測工具；兩入口各自實作；做成 Agent 執行器（Agent 要帶 3GB 資料且每台更新） |
| **D10** | Git 來源登記制 | **移未來項** | — | — |
| **D11** | 私有倉庫憑證 | **移未來項** | — | — |
| **D12** | 資源與時限 | 沙箱 mem 4g／cpus 2；單次掃描 20 分鐘；源碼包 100MB、解壓後 500MB、5 萬檔（沿用 FR-058.7）；SBOM 檔 50MB；**失敗不自動重跑**（`max_attempts=1`，顧問手動重掃）；全部可設定 | Grype 比對大 SBOM 吃記憶體；結果可重現，重跑只是浪費 | 不設上限；設更緊（1g／5 分） |
| **D13** | FR-058.7 上傳複用邊界 | **借檢查器不借流程**：沿用限額、副檔名白名單、`ProfileArchiveValidator`、`ArchiveBombGuard`；**新寫**「安全解壓到磁碟」放 jedi-detection 共用層；不接 SonarQube 的 orchestration | 檢查器通用；解壓位置與流程不同 | 整條沿用（解壓在 Agent 端、綁 Sonar）；全部重寫 |
| **D14** | AI 分析 | **有，但使用者主動按才跑**：報告頁「AI 分析」按鈕，按了才呼叫才扣額度；結果標「AI 生成，以上方統計為準」；**借 FR-122 AI 閘道**，只加一個提示樣板 | 與 D6 不衝突（預設只給事實，分析是使用者要的）；閘道現成，成本只有提示樣板 | 每次自動生成（花額度且替使用者下結論）；第一版不做 |

::: {.callout .decided}
**UI 設計約束（決策者兩度明示）**

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

> UI 不要直接抄襲，會沒辦法對外交代。／GIT 還沒做之前都不要在畫面上顯示。
> [— 決策者原話（2026-10-10）]{.attr}
:::

---

## 4. 現況接入點盤點 {#touchpoints nav="接入點"}

| 元件 | 座標 | 現況 | 本案動作 | 子任務 |
|---|---|---|---|---|
| 沙箱容器 | `docker/production/docker-compose.yml:580-622` | 唯讀、cap_drop ALL、禁提權、tmpfs 2g、mem 2g／cpus 2／pids 256；`user:` 靠 image | 抽成 x-anchor 樣板、mem 4g、掛 vulndb volume | T-A1 |
| 沙箱網段 | `docker-compose.yml:743-749` | 兩網段皆 `internal: true` | 不動 | — |
| 沙箱服務 | `jedi-evidence-classification/docker/classifier_service.py` | stdlib http.server，`/healthz`、`/v1/extract` | 加 `/v1/scan` | T-A3 |
| 沙箱 image build | `scripts/build/build_classifier_image.sh`；寫死處 `sbom.sh:101`、`vuln_gate.py:19`、`build_bundle.sh:93-108` | 不走 Nuitka | Dockerfile 加裝三支工具（釘版）；出貨弱點閘先跑白名單確認 | T-A2 |
| 沙箱客戶端 | `jedi_ai_gateway/infra/extract/sandbox_client.py:36-72` | 只有 `.extract()`，POST `/v1/extract` | 加 `.scan()`；`test_security_shape_external_calls.py:159` 登記 | T-A3、T-C3 |
| 守衛測試（容器） | `test/security/code/test_security_shape_container_hardening.py:17,28-38` | 四容器名寫死；不守 read_only／tmpfs／cpus／網段 | 改命名規則自動納入；補 read_only 與 internal 斷言 | T-A4 |
| installer／bundle | `scripts/installer/install.sh:1949-2016`（storage-seed 重灌）、`scripts/build/build_bundle.sh:258-297,369-385` | 非 image 資料隨包先例；manifest 只算 image | 收更新包進包、裝機／升級自動放入 volume、更新包進 manifest | T-B2、T-B3 |
| 出貨 SBOM 管線 | `scripts/build/sbom.sh:44-104`、`vuln_gate.py:84` | 工具裝 build 機；別名收集寫法 | 指令組合搬進沙箱；別名收斂借用 | T-A3、T-C4 |
| 背景工作 | `app/background_job/handler_registry.py:25`、`background_job_app_service.py:55` | `@register_job_handler`、`enqueue()`、lease 300s | 新 job type `sbom_scan`，`max_attempts=1` | T-C2 |
| 上傳限額與檢查 | `config/config.py:212-219`、`jedi_detection/common/detection_source_file.py:48-101`、`detection_profile_archive.py:99`、`archive_limits.py:51-99` | 限額、副檔名、zip-slip、炸彈檢查 | 直接用；新寫安全解壓 | T-C1 |
| 檔案儲存 | `jedi-file-upload` `ports.py:54`、`app/upload_file/service/managed_file_upload_service.py:35` | `save_file`／`get_file` | 原檔、SBOM、原始 JSON、報表都存這 | T-C2 |
| 全站請求上限 | `config/config.py:119` `MAX_REQUEST_BODY_MB=110`，`core/app_factory.py:113` | 110MB；要連前門 nginx 一起調 | **不動**；更新包上傳走分塊上傳 | T-B4 |
| PDF 報表 | `api/project_summary_report/routes/project_summary_report_route.py:31-62,163-182` | WeasyPrint、bleach、`_data_uri_only_url_fetcher`、Noto Sans CJK TC | 整條產生方式照抄 | T-E1 |
| DOCX 報表 | `app/oscal/service/export/ssp_docx_generator.py:46` | docxtpl＋python-docx | 範本填值方式照抄 | T-E2 |
| 差異比對 | `app/oscal/service/import_diff/ssp_diff_service.py:44` | 四態、key 配對 | 骨架借用，key 換 `fingerprint` | T-F1 |
| 能力點 | `core/host_capabilities.py`、`scripts/deliverables/gen_permission_matrix.py`、範例 `scripts/sql/2026-10-05-fr129-2-tenant-scan-zones.sql` | Python 宣告→產 SQL；三段式命名 | 宣告 `detection.source-scan.*`；migration 加 `ui_routes`＋`route_capabilities`＋bump `CAPABILITY_CATALOG.VERSION` | T-C5 |
| 授權照 | `common/authz/license.py:352` | `require_license` | 用 `plugin`（檢測工具包） | T-C5 |
| RLS | FR-129.2 migration `:21-60`；守衛 `test/security/database/test_rls_all_tenant_tables.py` | FOR ALL 一條規則 | 四張租戶表照抄 | T-C1 |
| 檢測工具目錄 | `config.detection_tools`；`GET /detection-tools`（`jedi_detection/api/routing.py:69`）；seed `jedi_detection/migrations/003-detection-tools-seed.sql` | 列 `status='available'` 且啟用；無執行位置概念 | 加 `execution_target` 欄；seed `sbom-scan` 一列＋參數 schema | T-H1 |
| 建任務選工具 | FE `components/grc/planning/AoTaskForm.vue:472-475` | `jobType==='detection_tool'` 時顯示工具下拉與 Agent 指派 | 依 `execution_target` 隱藏 Agent 指派區塊 | T-H4 |
| 檢測派工 | `jedi_detection/app/service/detection_orchestration_service.py:184` `start_execution`→`_dispatch_one`（:464）→`_pick_agent`（:1417）；**2,571 行** | 一律建 Agent 工單 | 新開檔 `server_side_dispatch.py` 依 `code` 查 handler 登記表；`start_execution` 一行分岔 | T-H2 |
| 檢測結果轉證據 | `detection_lifecycle_listener.py:24`→`DetectionResultHandler.handle_result`（`detection_result_handler.py:71`） | 存 `JOB_EVIDENCES/<job_uid>`→`job_evidences`→回填 `report_file_id`／`evidence_id`／`summary` | worker 跑完直接呼叫同一支（多檔結果） | T-H3 |
| 檢測檔案輸入 | FE `JobExecutionDrawer.vue:343-410,470-500`；BE `_resolve_row_source_files`（:718） | SonarQube 寫死 `scan_mode='upload'` 走隱藏檔案選擇 | 沿用，`scan_mode` 多 `sbom` 值 | T-H4 |
| 任務頁抽屜 | `JobExecutionDrawer.vue:904-980`（summary 中文對照）、:2156（執行列表） | 對照表寫死在 Vue | 加 SBOM 六區塊 key；加「看完整結果」連結 | T-H5 |
| 新模組接線 | `config/app_modules.py`、`di_containers/containers.py:145-146`；四層樣板 commit `6a35e2cf3` | — | 主專案 `api/source_scan/`＋DI | T-C6 |
| 前端樣板 | FE commit `6ae57765`（14 檔） | 選單由 `ui_routes` 驅動 | 整套照抄 | T-D1 |
| AI 閘道 | FR-122 `core/plugins/ai_gateway.py`、`jedi_ai_gateway` | provider／額度／呼叫紀錄／注入防護 | 加一個提示樣板，新端點呼叫 | T-E3 |
| 稽核事件埋點 | `docs/claude/memory/reference_audit_event_logging_pattern.md` | 既有 pattern | 更新包上傳、掃描建立／匯出各埋一筆 | T-B4、T-C6 |

---

## 5. 詳細設計 {#design nav="詳細設計"}

### 5.0 程式碼落點：新 jedi 套件 `jedi-source-scan`

核心引擎做成新套件 **`jedi-source-scan`**（`~/Projects/Jedicogy/module/jedi-python-package/jedi-source-scan/`），DDD 四層照 `jedi-detection` 的目錄形狀；主專案與檢測工具入口都只是它的呼叫端。理由：D9「核心不知道被誰叫」在套件邊界上最自然；出貨管線日後若要反過來吃產品服務，也只依賴套件。

| 東西 | 落點 |
|---|---|
| 資料模型、repository、正規化、統計、比對、報表產生 | `jedi_source_scan/{domain,infra,app}/` |
| job handler（`sbom_scan`） | `jedi_source_scan/app/jobs/sbom_scan_job.py` |
| 安全解壓 | `jedi_detection/common/safe_extract.py`（D13：放檢測共用層，兩套件都用） |
| 沙箱 `/v1/scan` 服務端 | `jedi-evidence-classification/docker/scan_service.py`（沙箱 image 本來就由該套件 build） |
| 沙箱客戶端 `.scan()` | `jedi_ai_gateway/infra/extract/sandbox_client.py`（既有 client 加方法） |
| 能力點宣告 | `jedi_source_scan/plugin/contract.py` `CAPABILITIES`（照 jedi_detection 契約形狀） |
| 主專案接線 | `api/source_scan/`、`di_containers/source_scan/`、`config/app_modules.py`、`core/plugins/source_scan.py` |
| 檢測工具入口分岔 | `jedi_detection/app/service/server_side_dispatch.py`（新檔） |
| 前端 | FE `src/views/source-scan/`、`src/service/SourceScanService.js`、i18n |

開發期走 poetry path dependency（主專案 `pyproject.toml` 取消註解），path 改動不 commit；發版等決策者明示。

### 5.1 沙箱擴充（子需求 A）

**compose 樣板**（`docker/production/docker-compose.yml`）：

```yaml
x-guidant-sandbox-hardening: &guidant-sandbox-hardening
  read_only: true
  cap_drop: [ALL]
  security_opt: ["no-new-privileges:true"]
  tmpfs:
    - /tmp:size=4g            # D12：掃描工作區（原 2g）
    - /home/classifier:mode=1777
  mem_limit: 4g               # D12
  cpus: 2
  pids_limit: 256
  # 預留：egress policy 由網段決定；第一版所有實例皆 internal（none）
```

`guidant-sandbox` 服務段改 `<<: *guidant-sandbox-hardening`，自己只留 image、environment、networks、volumes；volumes 加 `guidant-scan-vulndb:/opt/vulndb:ro`。頂層 `volumes:` 加 `guidant-scan-vulndb`。installer 的服務名清單不動（容器沒增）。

**image**（`jedi-evidence-classification/docker/Dockerfile`）：多階段加一層下載三支工具的釘版二進位（版本與出貨管線同：syft 1.54.0／trivy 0.75.0／grype 0.120.0，從 GitHub release 取並驗 sha256），放 `/usr/local/bin`；不裝任何弱點資料。Trivy／Grype 的快取目錄用環境變數指到 `/opt/vulndb/{trivy,grype}`。

**守衛測試**（`test/security/code/test_security_shape_container_hardening.py`）：

- `SANDBOX_SERVICES` 改為「compose 內所有名字以 `-sandbox` 結尾的服務」動態取得，舊的四容器 tuple 拿掉名單改規則，其餘三容器照原規則。
- 沙箱類新增三條斷言：`read_only is True`、所有 `networks` 都是 `internal: true`、`tmpfs` 有 `/tmp`。
- 突變測試：把 `read_only` 拿掉要紅、把網段 `internal` 拿掉要紅。

🔴 **出貨弱點閘**：三支工具本身進了 image，`scripts/build/sbom.sh --images` 會掃到它們的 Go 二進位依賴。A 階段 build 完先在 188 跑一次 `sbom.sh --images` 與 `vuln_gate.py`，有 Critical 就依 `vuln-allowlist.txt` 四欄格式登記（理由寫「掃描工具自身依賴，不對外暴露」，到期日給三個月），**不可為了過閘降版工具**。

### 5.2 弱點資料更新（子需求 B）

**更新包** `guidant-vulndb-<bundle_date>.tar.zst`（zstd -19 `--long=27`，解包端要帶同一旗標；`bundle_date` 取 Trivy／Grype／OSV／EPSS 四個每日來源中最舊的資料日期）：

| 內容 | 來源 | 解開後 | 說明 |
|---|---|---|---|
| Trivy DB `trivy/db/` | `trivy image --download-db-only` | 1.4 GB | java-db **不包**：產品走 syft 產 SBOM → `trivy sbom`，jar 座標由 syft 解出，有無 java-db 結果相同；只有 trivy 直接掃 jar 才需要（另 1.4 GB）。腳本留 `--with-java-db` |
| Grype DB `grype/6/` | `grype db update`（`vulnerability.db`＋`import.json`） | 3.0 GB | |
| OSV `osv/osv-scalibr/<生態系>/all.zip` | `osv-vulnerabilities.storage.googleapis.com/<生態系>/all.zip`，十個生態系（PyPI npm Maven Go crates.io RubyGems Packagist NuGet Pub Hex） | 287 MB（npm 約 218 MB 最大） | 資料日期取各筆 `modified` 最大值（zip 內檔案時間全是 1980、HTTP Last-Modified 是匯出時間）。目錄父層 `osv-scalibr` 是 osv-scanner 2.6 的查找路徑（2.0 是 `osv-scanner`），升版要對。沙箱只把有離線庫的生態系元件餵給 OSV——2.6 遇到沒有離線庫的生態系（deb、GitHub Actions…）會整次失敗 |
| CISA KEV `kev/` | `known_exploited_vulnerabilities.json` | 1.7 MB | 日期是清單最後異動日，不列入 `bundle_date` 計算 |
| EPSS `epss/` | `epss_scores-current.csv.gz` | 2.7 MB | 首行帶 `score_date` |
| `manifest.json` | `payload_type=vulndb_manifest`、`bundle_date`、`sources`（各來源資料日期／版本／筆數）、`files`（相對路徑→sha256）、`tool_versions` | — | |
| `manifest.sig` | 送 License Center 簽（沿用 FR-064 `sign_manifest.sh` 與同一把 PROD 鑰，kid `f3b562a6d9755527`）；LC 新增 `vulndb_manifest` 型，產品端以 `verify_payload(expected_type="vulndb_manifest")` 驗 | — | 獨立型別防「借簽」：弱點包信封不能拿去頂替產品或 agent 的啟動完整性清單 |

**實測（2026-10-11，開發機 macOS；188 實測待回填）**：壓縮後 **約 480 MB**（zstd -19）；解開 **4.7 GB**；產包耗時約 7 分鐘。新舊並存約 **9.5 GB**——安裝文件磁碟需求以此為準。產製腳本 `scripts/build/build_vulndb_bundle.sh`（新檔，`sbom.sh` 不動）；安裝包 `build_bundle.sh` 收一份進 `vulndb/`，**進 sha256 manifest**。

**進入途徑三條，寫入同一條**：

1. **裝機／升級**：`install.sh` 在起沙箱前把包內那份解進 `guidant-scan-vulndb`（比照 storage-seed 的 docker cp 寫法，`install.sh:1949-2016`）；升級時只在包內日期較新才覆蓋。客戶無感。
2. **設定頁上傳**：系統設定頁「弱點資料」區塊。因檔案超過 110 MB 全站上限，**走分塊上傳**（前端切 50 MB 塊逐段 PUT、後端落暫存合併；`MAX_REQUEST_BODY_MB` 與 nginx 都不動）。合併後驗 manifest 簽章、逐檔 sha256、`bundle_date` 不得早於現行 → 解進 volume 的新目錄 → 原子切換 symlink `current` → 清舊目錄。
3. **連網自動**：設定頁「檢查更新」按鈕與排程（預設每日），api 向自家站台 `GET /vulndb/latest`（URL 在 config，預設我們的站台）取 manifest 比日期，較新就由 worker 下載走同一條驗簽寫入。**沙箱不出網**，下載一律由 api／worker 做。

**更新紀錄**（決策者要求「誰什麼時間點上傳」）：表 `public.vulndb_updates`（平台層、無 tenant_id）：`uid`、`bundle_date`、`previous_bundle_date`、`source`（`installer`｜`ui_upload`｜`auto_download`）、`status`（`succeeded`｜`failed`）、`error`、`sha256`、`sources`（JSONB：各來源日期筆數）、`created_user`（installer 寫 `system`）、`created_at`。設定頁顯示最近 20 筆，同時走既有稽核事件埋點（事件 `vulndb.update`）。

**使用端**：掃描時 Trivy 帶 `--skip-db-update --offline-scan --cache-dir /opt/vulndb/current/trivy`，Grype 設 `GRYPE_DB_AUTO_UPDATE=false`、`GRYPE_DB_CACHE_DIR=/opt/vulndb/current/grype`；KEV／EPSS 由 worker 正規化階段讀（不在沙箱內）。每筆 `source_scans.vulndb_date` 記當次資料日期；結果頁與報表顯示，超 30 天黃、90 天紅。

### 5.3 核心引擎（子需求 C）

**資料模型**（schema `compliance`，共同欄位 `id`、`uid`、`tenant_id NOT NULL`、`org_unit_id`、`created_user`／`updated_user`、`created_at`／`updated_at`；RLS 照 FR-129.2 一條 FOR ALL；`GRANT` 表與 sequence 給 `cm_app`；收尾 `INSERT schema_migrations`）：

| 表 | 關鍵欄位 |
|---|---|
| `source_scan_targets` | `name`、`client_note`、`contact_note`、`source_type`（`upload`｜`sbom`；字串欄、API 枚舉只開放這兩值，送其他值回 400）、`source_ref` JSONB、`latest_scan_uid` |
| `source_scans` | `target_uid`（**可空**）、`detection_execution_uid`（**可空**）、`source_type`、`source_ref` JSONB、`input_file_id`、`status`（queued／running／done／failed）、`background_job_uid`、`tools` JSONB、`tool_versions` JSONB、`vulndb_date`、`summary` JSONB（六區塊）、`sbom_quality` JSONB（NTIA 七要素＋缺 lock 清單）、`raw_outputs` JSONB（檔案 id＋sha256）、`sbom_files` JSONB（格式＠版本→檔案 id）、`report_file_id`、`error`、`started_at`、`finished_at` |
| `source_scan_components` | `scan_uid`、`purl`、`name`、`version`、`ecosystem`、`licenses` JSONB、`license_risk`（copyleft／permissive／unknown） |
| `source_scan_findings` | `scan_uid`、`component_purl`、`vuln_id`（CVE 優先）、`aliases` JSONB、`severity`、`cvss_score`、`trivy` JSONB（reported／severity／score／advisory_url）、`grype` JSONB、`fixed_versions` JSONB、`oldest_fix_date`、`in_kev`、`kev_date_added`、`epss_score`、`match_key`、`fingerprint`、`description`、`nvd_url` |

兩個 key：`match_key`＝`ecosystem+name+version+正規化弱點編號`（跨工具配對，別名收斂 GHSA／PYSEC→CVE，借 `vuln_gate.py:84` 收 `relatedVulnerabilities` 的寫法）；`fingerprint`＝`ecosystem+name+正規化弱點編號`（**不含版本**，升版仍受影響算「仍在」）。

**job handler** `sbom_scan`（`@register_job_handler('sbom_scan')`，`max_attempts=1`）：payload 只放 `scan_uid`（無金鑰）。流程：

1. `report_progress({'stage':'receive'})`：從儲存取原檔到工作目錄（沿用 `stage_to_dir` pattern）；`source_type=upload` 走 `ProfileArchiveValidator`＋`ArchiveBombGuard` 檢查 → **`safe_extract`**（新寫，§D13）；`source_type=sbom` 驗 JSON／XML 格式與 CycloneDX／SPDX 版本辨識（schema 驗證在沙箱內做）。
2. `report_progress({'stage':'sbom'})`：`SandboxClient.scan(job_uid, files, options)` → 沙箱 `/v1/scan`：`syft dir:<工作區> -o cyclonedx-json@1.6=... -o spdx-json@2.3=...`（依選項多格式多版本）；`source_type=sbom` 時略過 syft、直接把上傳檔當輸入。
3. `report_progress({'stage':'vuln'})`：沙箱內 `trivy sbom <cdx> --format json --skip-db-update --offline-scan`、`grype sbom:<cdx> -o json`；回傳 SBOM 檔案群＋兩份原始 JSON。
4. `report_progress({'stage':'report'})`：worker 正規化——解析兩份 JSON 成統一 finding、`match_key` 配對、KEV／EPSS enrich（讀 volume 內檔）、六區塊統計、NTIA 七要素、缺 lock 偵測（Syft 輸出的 cataloger 來源判斷：只有 `python-package-cataloger` 類而無 lock cataloger）、授權風險；存 SBOM／原始 JSON（算 sha256）進儲存；寫三張表；`status=done`。

沙箱逾時 20 分鐘（D12）；任何階段失敗 `status=failed` 寫 `error`，不重跑。worker 啟動時沿用 `sweep_stale_job_dirs` 清殘留。

**安全解壓** `jedi_detection/common/safe_extract.py`：`safe_extract(archive_path, dest_dir, limits) -> ExtractReport`，照 `.claude/rules/security/uploads-parsing.md` 四道：禁 `extractall`、每個 entry `realpath` 必在 `dest_dir` 內、拒 symlink／hardlink／裝置檔、邊解邊累計 bytes 與檔數超限即中止、工作目錄不得是符號連結。呼叫端在沙箱內（沙箱 `/v1/scan` 收到的是壓縮檔，解壓在沙箱裡做）。

**沙箱端點** `/v1/scan`（`scan_service.py`）：Bearer token 同 `/v1/extract`；輸入多部檔案＋JSON 選項；單一併發（`--max-concurrent 1` 不變）；對外回傳一律視為外部輸入（worker 解析時 bleach 不適用、改走嚴格 JSON schema）。`SandboxClient.scan()` 登記進 `test_security_shape_external_calls.py`。

**六區塊 summary 形狀**（對齊 `detection_executions.summary`，兩入口共用）：

```json
{"severity":{"trivy":{"critical":1,"high":9,"medium":15,"low":3},"grype":{...}},
 "kev":{"count":1,"items":["CVE-..."]},
 "epss":{"threshold":0.1,"count":2,"items":[...]},
 "fixable":{"with_fix":7,"without_fix":3,"oldest_fix_date":"2023-04-01"},
 "coverage":{"missing_lock":["npm"],"ntia":{"score":5,"missing":["supplier","author"]}},
 "delta":{"vs_scan_uid":"...","fixed":4,"new":2,"remaining":8},
 "vulndb_date":"2026-10-08","components":568,"findings":28}
```

### 5.4 入口 1 獨立模組

**能力點**：`detection.source-scan.{read,create,update,delete,execute,export}` 宣告在 `jedi_source_scan/plugin/contract.py`；跑 `scripts/deliverables/gen_permission_matrix.py` 重產 FE `capabilities.js`；migration 加 `ui_routes`（路徑 `/source-scan`，`license_module='plugin'`）＋`route_capabilities`（ALL：`detection.source-scan.read`）＋bump `CAPABILITY_CATALOG.VERSION`。`shipped-roles.json`：稽核主管／稽核員 pattern 加 `detection.source-scan.*` 扣 `delete`、`update`；唯讀 `detection.source-scan.read`。弱點資料設定併入系統設定頁（`system.system-config.update`，管理員）。

**API**（`api/source_scan/routes/`，全部 `@jwt_required → @require_license("plugin") → @require_capability(...)`，資源域守門在 app service 查 target 屬租戶）：

| 方法 | 路徑 | 能力點 | 說明 |
|---|---|---|---|
| POST | `/source-scan/targets` | create | 建對象（分頁列表 POST `/source-scan/targets/list`） |
| GET／PUT／DELETE | `/source-scan/targets/<uid>` | read／update／delete | |
| POST | `/source-scan/targets/<uid>/scans` | execute | multipart 上傳＋選項 → 建 scan、入列；回 `scan_uid` |
| GET | `/source-scan/scans/<uid>` | read | 狀態、進度（四段）、summary |
| GET | `/source-scan/scans/<uid>/findings` | read | 分頁、排序參數（`kev,epss,cvss`）、篩選 |
| GET | `/source-scan/scans/<uid>/components` | read | |
| GET | `/source-scan/scans/<uid>/files/<kind>` | read | 下載 SBOM（`?format=cyclonedx&version=1.6`）／原始 JSON，由伺服器讀檔頭定型別＋nosniff |
| POST | `/source-scan/scans/<uid>/export` | export | `style`×`format` → 檔案 |
| GET | `/source-scan/targets/<uid>/diff?a=&b=` | read | 歷次比對 |
| POST | `/source-scan/scans/<uid>/ai-analysis` | read | D14，呼叫 AI 閘道 |
| GET／POST | `/system/vulndb/status`、`/system/vulndb/upload`（分塊）、`/system/vulndb/check-update` | system-config | 設定頁 |

審計欄位回傳 `created_user_name`／`updated_user_name`（app service 批次轉 nickname）。

**FE**（照 FR-129.2 commit `6ae57765` 14 檔形狀）：`router`、`api.js`、`SourceScanService.js`、`config/sourceScan.js`、`views/source-scan/{SourceScanTargetList,SourceScanWizard,SourceScanResult,SourceScanDiff}.vue`、i18n `source-scan.json`（zh-tw／en）＋`menu.json`＋`error-code.json`；元件級 `v-can`。**每支 Vue 不超過 800 行**，結果頁三層各拆子元件。

畫面骨架（自行設計，見 §3 UI 約束）：①對象清單（名稱、客戶備註、來源、最近掃描日、重點數字、趨勢；按鈕新增／重掃／看結果／歷次比對）；②精靈三頁（這是什麼／程式在哪：**只有**上傳源碼包與上傳 SBOM 檔兩選一／怎麼掃：標準掃描＋進階勾工具與格式版本）；送出後清單列顯示四段進度；③結果頁三層分頁（事實儀表板六區塊＋授權風險＋資料日期警示／對照層每弱點一列排序可選、兩家不一致標示／原始層下載）；④歷次比對三欄；⑤設定頁（弱點資料日期、上傳更新包、檢查更新、更新歷史、資源上限）。

### 5.5 入口 2 檢測工具（子需求 H）

| 環節 | 做法 | 座標 |
|---|---|---|
| 工具目錄 | migration：`config.detection_tools` 加 `execution_target VARCHAR(16) NOT NULL DEFAULT 'agent'`（`agent`｜`server`）；seed 一列 `code='sbom-scan'`、`name='SBOM 弱點掃描'`、`execution_target='server'`、`requires_target_host=false`、`requires_credentials=false`、`status='available'`、`enabled=true`；參數 schema 一版：`scan_mode`（select：`upload`｜`sbom`）、`sbom_formats`（multiselect）、`tools`（multiselect 預設三支） | `jedi_detection/migrations/`（新檔，照 `003-detection-tools-seed.sql` 形狀） |
| `GET /detection-tools` | 回應加 `execution_target` | `jedi_detection/api/routing.py:69` 對應 serializer |
| 建任務 | `AoTaskForm.vue` 工具選定後若 `execution_target==='server'`：隱藏 Agent 指派區塊與目標主機欄，送出的 `agent_assignments` 為空；BE `_replace_detection_tool_binding` 對 server 型不要求分派列 | FE `AoTaskForm.vue:472-475`；`detection_job_binding_handler.py:164` |
| 派工 | **新檔** `jedi_detection/app/service/server_side_dispatch.py`：`SERVER_SIDE_HANDLERS = {'sbom-scan': enqueue_sbom_scan}` 登記表；`dispatch(job, tool, params) -> execution`：建 `detection_executions` running 列（`agent_task_uid` NULL、`group_uid` 照建）、呼叫對應 handler 入列 `background_jobs`（payload：`scan_uid`，先建 `source_scans` 列 `target_uid=NULL`、`detection_execution_uid=<新列>`）。`start_execution` 在守門之後、`_plan_from_assignments` 之前**一行**：`if tool.execution_target == 'server': return server_side_dispatch.dispatch(...)`。不碰 `_dispatch_one`／`_pick_agent` | `detection_orchestration_service.py:184`（只加一行） |
| 檔案輸入 | 沿用 sonar 上傳機制：抽屜判 `scan_mode in ('upload','sbom')` 顯示檔案選擇，`upload_type:'DETECTION_SOURCE'`，執行時送 `source_files`；`_resolve_row_source_files` 對 server 型回傳檔案 uid 給 dispatch | FE `JobExecutionDrawer.vue:343-410,470-500`；BE `:718` |
| 自動首掃 | `on_tasks_activated` 對 server 型同樣觸發 `start_execution`，但 server 型若尚未有 `source_files` 則不自動跑、抽屜提示「請上傳檔案後執行」 | `task_type_declaration.py:43` |
| 結果 | job handler 跑完若 `detection_execution_uid` 非空：組 `ScanResult` 清單（報表 PDF、CycloneDX、SPDX、兩份原始 JSON，各帶 filename／content_type／summary）呼叫 `DetectionResultHandler.handle_result`；既有流程寫 `job_evidences`、回填 `report_file_id`／`evidence_id`／`summary`、群組收口與 `completion_mode=auto` 判斷零改動 | `detection_result_handler.py:71` |
| 抽屜 | summary 中文對照表加六區塊 key（嚴重度／KEV／EPSS／可修未修／涵蓋／元件數）；執行列加「看完整結果」連 `/source-scan/scans/<uid>`（由 `source_scans.detection_execution_uid` 反查） | `JobExecutionDrawer.vue:904-980,2156` |
| 授權 | 同一張檢測工具包；沒買的租戶 `GET /detection-tools` 本來就被 license 過濾 | — |

核心不判斷入口：差異只在「誰建 `source_scans` 列」與「跑完要不要多呼叫 `DetectionResultHandler`」（看 `detection_execution_uid` 是否非空）。

### 5.6 報表與 AI 分析（子需求 E）

| 樣式 | 內容 | 讀者 |
|---|---|---|
| 顧問摘要 | 封面、六區塊統計、KEV／EPSS 清單、可修未修前 20、涵蓋完整度、附錄、資料來源說明 | 顧問開會用 |
| 完整技術 | 以上＋全部弱點列表（工具、CVE、公告連結、修正版）＋元件清單＋授權清單 | 交工程師 |
| 客戶交付 | 顧問摘要去掉內部備註、加客戶名與日期欄 | 交客戶 |

格式：**HTML**（Jinja 模板，單檔內嵌 CSS，可展開原始片段）→ **PDF**（同一份 HTML 經 WeasyPrint，照 `project_summary_report_route.py` 的 bleach 消毒與 `_data_uri_only_url_fetcher`，字型 Noto Sans CJK TC）；**DOCX**（docxtpl 範本 `jedi_source_scan/templates/report_*.docx`，照 `ssp_docx_generator.py` 填值）。共用品牌頁首頁尾放模板內（產品名、租戶名、產生時間、頁碼）。

硬規則：**不出現結論句**（「屬高風險」「建議立即」），只出事實句；每個弱點列四件事（工具、CVE 連 `https://nvd.nist.gov/vuln/detail/<id>`、advisory 連結、fixed version）；附錄一頁（工具與版本、弱點資料日期、各原始檔 sha256 與系統內路徑）；資料來源說明一頁（NVD／CVSS—NIST、CISA KEV、EPSS—FIRST、套件官方修正紀錄）。缺 lock 時「涵蓋完整度」段必在統計同頁、不得只在附錄。

**AI 分析**（D14）：結果頁按鈕 → `POST /source-scan/scans/<uid>/ai-analysis` → 經 FR-122 AI 閘道（既有 provider 設定、額度、呼叫紀錄、Prompt Guard）送提示樣板（輸入只放 summary 與前 20 筆 finding 的事實欄位，不放原始 JSON）；回應存 `source_scans.ai_analysis` JSONB（文字、模型、時間、誰按的）；畫面標「AI 生成，以上方統計為準」。報表不含 AI 分析。

### 5.7 歷次比對（子需求 F）

`SourceScanDiffService.diff(scan_a, scan_b)`，借 `SspDiffService` 骨架：以 `fingerprint` 配對 findings → `fixed`（a 有 b 無）、`new`（a 無 b 有）、`remaining`（兩邊都有，版本可能不同，附兩邊版本）；components 以 `purl` 去版本配對列升降版。結果不落表、即時算；`source_scans.summary.delta` 在每次掃描完成時對 `target.latest_scan_uid` 自動算一次存入。回訪報告＝顧問摘要樣式加「與上次比」一節。

### 5.8 資安規則對應

| 規則檔 | 本案落實 |
|---|---|
| `uploads-parsing.md` | 四道：限額先量、串流收檔、解壓前 sha256、`safe_extract`；下載端點伺服器讀檔頭定型別＋nosniff；沙箱與工具回傳一律視為外部輸入（嚴格 JSON schema 解析、長度上限） |
| `secrets.md` | job payload 只放 `scan_uid`；更新包簽章驗證公鑰隨產品、私鑰在 build 機（沿用 FR-064 鑰管理）；log 不記檔案內容 |
| `external-calls` 守衛 | `SandboxClient.scan`、連網檢查更新的 `requests.get` 兩筆登記 |
| 容器加固 | §5.1 守衛改規則；`read_only`／`internal` 新斷言 |
| `logging.md` | 掃描建立／完成／失敗、報表匯出、更新包上傳各埋稽核事件（誰、何時、對象 uid、結果） |
| 新模組路徑 | `.claude/rules/security/*.md` 的 `paths` 加 `api/source_scan/**`、`jedi-source-scan/**`（G 階段） |

---

## 6. 拆分 {#breakdown nav="拆分"}

粒度：一張子任務＝一個 session 做得完；每張卡開卡時把本節對應列＋§5 對應段展開進卡。

| 子任務 | 做什麼（白話） | 主要檔案 | 驗收 | 依賴 |
|---|---|---|---|---|
| **T-A1** compose 樣板與 volume | 加固設定抽成 x-anchor，沙箱改吃；加 vulndb volume 唯讀掛入；mem 4g | `docker/production/docker-compose.yml`、`.env.example` | `docker compose config` 展開後沙箱帶 read_only／cap_drop ALL／4g；掛載 `/opt/vulndb:ro` | — |
| **T-A2** 沙箱 image 加工具 | Dockerfile 加三支釘版二進位並驗 sha256；build 腳本不變 | `jedi-evidence-classification/docker/Dockerfile`、`scripts/build/build_classifier_image.sh` | 進容器三支 `version` 都有；image 大小增量記錄；`sbom.sh --images` 跑過、白名單處理完 | T-A1 |
| **T-A3** 沙箱 `/v1/scan`＋客戶端 `.scan()` | 沙箱新端點收檔與選項、跑 syft／trivy／grype（離線參數）、回多檔；client 加方法並登記外呼守衛 | `jedi-evidence-classification/docker/scan_service.py`、`jedi_ai_gateway/.../sandbox_client.py`、`test_security_shape_external_calls.py` | DEV 用 curl 送範例 zip 回 SBOM 兩格式＋兩份 JSON；逾時 20 分生效；守衛綠 | T-A2 |
| **T-A4** 守衛測試改規則 | 名字 `-sandbox` 結尾自動納入；補 read_only／internal／tmpfs 斷言；突變驗證 | `test/security/code/test_security_shape_container_hardening.py` | 全綠；拿掉 read_only 紅、拿掉 internal 紅 | T-A1 |
| **T-B1** 更新包產製腳本 | build 機下載四來源、組 manifest、簽章、打包；量實際大小回填設計 | `scripts/build/build_vulndb_bundle.sh`（新） | 188 跑一次出 `.tar`；manifest 列四來源日期；驗章通過；實測大小寫進 §5.2 | — |
| **T-B2** 安裝包收更新包＋裝機自動放入 | bundle 收 `vulndb/`、進 sha256 manifest；install 首裝解進 volume、升級較新才覆蓋 | `scripts/build/build_bundle.sh`、`scripts/installer/install.sh` | 乾淨機裝完 volume 有 `current/`；升級拿舊包不覆蓋、新包覆蓋；manifest 校驗含 vulndb | T-B1、T-A1 |
| **T-B3** 更新包寫入服務＋紀錄表 | 驗簽→逐檔 sha256→日期不倒退→解進新目錄→原子切換→清舊；`vulndb_updates` 表與 migration；稽核埋點 | `jedi_source_scan/app/service/vulndb_update_service.py`、`scripts/sql/2026-10-xx-fr134-vulndb-updates.sql` | 改一個位元組被拒；舊日期被拒；成功後 `current` 指新目錄、紀錄一列含 `created_user` | T-B1 |
| **T-B4** 設定頁：上傳（分塊）、檢查更新、排程、歷史 | 分塊上傳端點與前端；連網檢查自家站台；每日排程；設定頁區塊與歷史列表（最近 20 筆） | `api/source_scan/routes/vulndb_route.py`、FE 設定頁元件、`config/config.py`（站台 URL 變數，先查既有同義變數） | 瀏覽器上傳 300MB 包成功、日期前進、歷史多一列寫誰何時；斷網時檢查更新回白話錯誤 | T-B3 |
| **T-C1** 資料表 migration＋安全解壓 | 四張租戶表＋RLS＋GRANT；`safe_extract` 含單元測試（zip-slip、symlink、炸彈） | `scripts/sql/2026-10-xx-fr134-source-scan-tables.sql`、`jedi_detection/common/safe_extract.py`、`test/` | RLS 守衛綠；`safe_extract` 測試綠且突變紅；回報「出貨基線待重產」 | — |
| **T-C2** 套件骨架＋job handler | `jedi-source-scan` 四層、entity／repo／mapper；`sbom_scan` handler 四段進度、`max_attempts=1`；儲存讀寫 | `jedi-source-scan/`、主專案 `core/plugins/source_scan.py` DI | DEV 入列一筆跑完 `status=done`，進度四段依序出現 | T-C1、T-A3 |
| **T-C3** 兩種輸入路徑 | `upload` 走檢查→解壓→syft；`sbom` 走格式辨識（CycloneDX／SPDX、JSON／XML、版本）→略過 syft；`source_type` 枚舉守門（送 `git` 回 400） | `jedi_source_scan/app/service/scan_input_service.py` | 同一專案的源碼包與其 SBOM 掃出弱點數一致；送 `git` 回 400 | T-C2 |
| **T-C4** 正規化層 | 兩份 JSON→統一 finding；別名收斂＋`match_key`／`fingerprint`；KEV／EPSS enrich；六區塊 summary；NTIA 七要素；缺 lock 偵測；授權風險 | `jedi_source_scan/app/service/normalize/` | 固定範例輸入的 summary 與手算一致；兩家同報一筆只出一列；GHSA↔CVE 合併 | T-C2 |
| **T-C5** 能力點、授權、角色 | contract 宣告、產矩陣、`ui_routes`＋`route_capabilities` migration＋catalog bump、shipped-roles | `jedi_source_scan/plugin/contract.py`、`scripts/sql/...`、`resources/roles/shipped-roles.json` | 守衛 `test_capability_migration_bumps_catalog_version` 綠；唯讀角色打 POST 回 403；沒買檢測工具包的租戶選單不出現 | T-C1 |
| **T-C6** API 路由＋app service | §5.4 表全部端點；分頁、排序、篩選；下載端點型別與 nosniff；審計欄位暱稱；稽核埋點 | `api/source_scan/`、`jedi_source_scan/app/service/source_scan_app_service.py`、`di_containers/source_scan/` | Swagger 全列可打；route guard inventory 守衛綠；ddd-compliance-reviewer 無阻斷 | T-C3、T-C4、T-C5 |
| **T-D1** 對象清單＋精靈 | 清單頁、精靈三頁（頁 2 只兩選項）、四段進度輪詢 | FE `views/source-scan/`、Service、i18n | 新增一筆跑完；唯讀看不到新增；畫面無 Git 字樣 | T-C6 |
| **T-D2** 結果頁三層 | 事實儀表板六區塊＋授權風險＋資料日期警示；對照層排序可選與不一致標示；原始層下載 | FE 子元件三支 | 六區塊數字與 API 一致；表頭顯示排序規則；每支 Vue ≤800 行 | T-D1 |
| **T-D3** 設定頁區塊 | 與 T-B4 前端合一張：弱點資料狀態、上傳、檢查更新、歷史、資源上限 | FE 系統設定頁 | 見 T-B4 | T-B4 |
| **T-E1** HTML／PDF 報表 | 三樣式 Jinja 模板、品牌頁首頁尾、附錄、資料來源頁；WeasyPrint 照既有防護 | `jedi_source_scan/templates/`、export service | 六份（3 樣式×2 格式）中文不缺字；grep 報表無結論句字詞；附錄 sha256 與儲存檔一致 | T-C6 |
| **T-E2** DOCX 報表 | docxtpl 三範本 | `jedi_source_scan/templates/*.docx`、`scripts/generate_source_scan_docx_template.py` | 三份開啟正常、表格完整 | T-E1 |
| **T-E3** AI 分析按鈕 | 端點＋提示樣板經 AI 閘道；存結果；畫面標示 | `api/source_scan/routes/ai_route.py`、FE 按鈕 | 按才呼叫（AI 呼叫紀錄多一筆）；不按零呼叫；標示文字存在 | T-C6 |
| **T-F1** 比對服務＋頁面＋回訪報告 | `fingerprint` 三欄比對；自動算 delta；比對頁；回訪報告樣式 | `jedi_source_scan/app/service/source_scan_diff_service.py`、FE `SourceScanDiff.vue` | 升版再掃「修掉」出現；仍受影響版本留「仍在」 | T-C6、T-D2 |
| **T-H1** 工具目錄加欄位＋seed | `execution_target` 欄、`sbom-scan` 列、參數 schema；`GET /detection-tools` 回欄位 | `jedi_detection/migrations/`（新）、serializer | 工具清單 API 多一列且帶 `server` | T-C1 |
| **T-H2** 伺服器端派工分岔 | 新檔 `server_side_dispatch.py` 登記表；`start_execution` 一行；建 execution 列與 `source_scans` 列並入列 | `jedi_detection/app/service/server_side_dispatch.py`、`detection_orchestration_service.py`（一行） | 無 Agent 在線也能跑完；`agent_task_uid` NULL；orchestration 檔 diff 只有一行 | T-H1、T-C2 |
| **T-H3** 結果轉證據 | handler 完成時組多檔 `ScanResult` 呼叫 `DetectionResultHandler.handle_result` | `jedi_source_scan/app/jobs/sbom_scan_job.py` | 任務證據清單出現 PDF、兩份 SBOM、兩份 JSON；`detection_executions.summary` 等於 `source_scans.summary` | T-H2、T-E1 |
| **T-H4** 建任務與抽屜檔案輸入 | `AoTaskForm` 依 `execution_target` 隱藏 Agent 區塊；binding handler 對 server 不要求分派；抽屜 `scan_mode` 多 `sbom` | FE `AoTaskForm.vue`、`JobExecutionDrawer.vue`；`detection_job_binding_handler.py` | 建任務無 Agent 欄；送出成功；抽屜能上傳 SBOM 檔 | T-H1 |
| **T-H5** 抽屜 summary 對照與連結 | 六區塊 key 中文對照；「看完整結果」連獨立模組 | `JobExecutionDrawer.vue:904-980,2156` | 抽屜顯示六區塊；連結進結果頁數字一致 | T-H3、T-D2 |
| **T-G1** SPEC 頁 | `docs/spec-site/current/` 新頁（獨立模組、檢測工具入口、設定頁） | spec-site | `writing-feature-specs` skill 走完 | A–H |
| **T-G2** 使用手冊＋安裝文件 | 手冊三件事；安裝文件磁碟需求（+9GB，見 §5.2 實測）、更新包取得方式 | `docs/user-manual/`、installer docs | 手冊找得到三件事 | A–H |
| **T-G3** 資安規則路徑＋域能力索引 | `.claude/rules/security/*.md` paths 加新模組；`domain-capabilities.md` 登記 `safe_extract`、`SandboxClient.scan` | 規則檔、索引 | Edit 新模組檔時規則自動載入 | A–H |
| **T-G4** 套件發版＋pin 還原 | `jedi-source-scan` 首版、`jedi-detection`／`jedi-evidence-classification`／`jedi-ai-gateway` 進版；主專案 pin | `pyproject.toml`、Nexus | **等決策者明示**；pin 還原後全守衛重跑 | A–H |

```{.mermaid cap="圖 2 — 子任務依賴"}
%%{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
  A1[T-A1] --> A2[T-A2] --> A3[T-A3]
  A1 --> A4[T-A4]
  B1[T-B1] --> B2[T-B2]
  A1 --> B2
  B1 --> B3[T-B3] --> B4[T-B4] --> D3[T-D3]
  C1[T-C1] --> C2[T-C2]
  A3 --> C2
  C2 --> C3[T-C3]
  C2 --> C4[T-C4]
  C1 --> C5[T-C5]
  C3 --> C6[T-C6]
  C4 --> C6
  C5 --> C6
  C6 --> D1[T-D1] --> D2[T-D2]
  C6 --> E1[T-E1] --> E2[T-E2]
  C6 --> E3[T-E3]
  C6 --> F1[T-F1]
  D2 --> F1
  C1 --> H1[T-H1] --> H2[T-H2]
  C2 --> H2
  H2 --> H3[T-H3]
  E1 --> H3
  H1 --> H4[T-H4]
  H3 --> H5[T-H5]
  D2 --> H5
  D2 --> G[T-G1～G4]
  E2 --> G
  F1 --> G
  H5 --> G
  B4 --> G
```

---

## 7. 端到端驗收 {#acceptance nav="驗收"}

決策者在 DEV 親手做（全部跑完再收集失敗，不逐項修）：

1. **沙箱**：`docker exec guidant-sandbox syft version`、`trivy --version`、`grype version` 三支有；`docker exec guidant-sandbox curl -m 5 https://github.com` 失敗；`docker compose config | grep -A20 guidant-sandbox` 看到 `read_only: true` 與 4g；既有證據分類跑一筆照常。
2. **守衛**：`pytest test/security/code/test_security_shape_container_hardening.py` 綠；手動把 compose 沙箱 `read_only` 註解掉再跑 → 紅。
3. **弱點資料**：設定頁顯示資料日期；上傳 T-B1 產的包 → 日期前進、歷史多一列寫自己帳號與時間；把包內任一檔改一個位元組再上傳 → 拒收並有白話錯誤；拿舊日期包 → 拒收。
4. **獨立模組**：以稽核主管登入 → 側選單有「原始碼掃描」→ 新增對象、上傳一個含 `package-lock.json` 的範例專案 zip → 清單四段進度走完 → 結果頁六區塊有數字、KEV 區塊有列、對照層切換排序表頭跟著變、原始層下載 CycloneDX 1.6 與 SPDX 2.3 兩檔都能開。
5. **缺 lock**：上傳一個只有 `package.json` 的 zip → 結果頁「涵蓋完整度」明確標示 npm 缺 lock、品質分較低。
6. **SBOM 上傳**：把步驟 4 下載的 CycloneDX 檔新建對象上傳 → 弱點數與步驟 4 一致。
7. **報表**：匯出 3 樣式×3 格式九份；PDF 中文不缺字；全文搜尋「高風險」「建議立即」為零；附錄 sha256 與原始層顯示一致。
8. **AI 分析**：不按按鈕 → AI 呼叫紀錄無新列；按 → 多一列、畫面出現「AI 生成」標示。
9. **歷次比對**：把範例專案某套件升到有修正的版本重壓 zip、同對象重掃 → 比對頁「修掉」出現該弱點；改成仍受影響的另一版 → 「仍在」。
10. **檢測工具入口**：專案規劃建 `detection_tool` 任務 → 下拉有「SBOM 弱點掃描」、選後無 Agent 指派欄 → 任務頁上傳 zip 按執行（**所有 Agent 離線**）→ 執行列表一筆跑完 → 任務證據清單出現 PDF 與 SBOM → 「看完整結果」數字與獨立模組一致。
11. **權限與授權**：唯讀角色看不到新增、打 POST 回 403；以沒買檢測工具包的租戶登入 → 側選單無此項、檢測工具下拉無此列。
12. **畫面**：精靈、清單、設定頁全文搜尋「Git」為零；版面與 ITRI SecureScan 截圖無可辨識雷同。

---

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

::: {.callout .crit}
**工具進沙箱 image 會被出貨弱點閘掃到**：Syft／Trivy／Grype 是 Go 二進位，各自帶上百個 Go 模組，`sbom.sh --images` 會列出它們的 CVE。T-A2 build 完**先跑閘**，Critical 依白名單四欄格式登記（理由、到期日），不可降版工具硬過。
:::

::: {.callout .crit}
**派工服務檔 2,571 行，超標三倍**：`detection_orchestration_service.py` 任何新增邏輯一律新開檔（`server_side_dispatch.py`），`start_execution` 只准多一行呼叫；ddd-compliance-reviewer 看到該檔 diff 超過一行即阻斷。`JobExecutionDrawer.vue` 2,721 行同理——T-H4／H5 的改動能抽子元件就抽。
:::

- **全站 110MB 上限**：更新包約 300MB，設定頁上傳必走分塊；不可為此調 `MAX_REQUEST_BODY_MB`（會連帶放寬所有端點）。
- **磁碟**：volume 解開 3.5GB、更新時 7GB，安裝文件與 installer 前置檢查要加。
- **證據分類排隊**：共用一顆沙箱，大源碼包掃描 20 分鐘內分類會等；C 階段量測，不可接受時調 `--max-concurrent`，不拆容器。
- **缺 lock 看起來很乾淨**：弱點數可能為零但涵蓋不完整；「涵蓋完整度」必與統計同頁顯示，不得只在附錄。
- **兩家工具不一致**：同一 CVE 一家報 High 一家 Critical 是常態；對照層標「工具意見不一致」並列兩家分數，不取平均。
- **Trivy Java**：掃 Java 專案可能需要 `trivy-java-db`，B 階段實測、必要時進更新包。
- **`source_type` 枚舉**：資料層預留 `git` 但 API 與 UI 不開放；守衛可加一條「序列化器枚舉只含 upload／sbom」。
- **沙箱回傳是外部輸入**：工具 JSON 解析用嚴格 schema 與長度上限，不信任 severity 字串直接進 DB（白名單對照）。
- **自動首掃**：server 型任務啟動時若無檔案不可自動跑失敗一筆，抽屜要提示先上傳。
- **出貨基線**：T-C1／T-H1／T-B3 的 migration 完成要回報「出貨基線待重產」，決策者裁示後才動 188 基線庫。

---

## 9. 已知未做與後續 {#followups nav="未做"}

| 項目 | 說明 | 時機 |
|---|---|---|
| Git 來源（含私有倉庫憑證、出網白名單、逐 repo 登記） | 討論稿「未來項」D2／D10／D11 原文保留；需另開抓檔容器（套同一份樣板、出網政策 `git-allowlist`）、資料模型只加 `source_type='git'` 與 `source_ref` 內容 | 第二版 |
| 伺服器端自動化任務通用機制 | 工具目錄加分類、授權按工具列、summary 對照由工具列自帶、任務型態顯示名；本案只留 `server_side_dispatch` 登記表地基 | 大整理 |
| cdxgen／sbomqs | 建置型解析需客戶套件鏡像；sbomqs 品質分 | 第二版評估 |
| 定期重掃與告警 | 新弱點資料進來後對既有掃描重比對、通知 | 第二版 |
| 容器 image 掃描 | 本案只收源碼與 SBOM | 不在範圍 |
| 授權合規法律意見 | 只列授權類型與傳染性，不給法律結論 | 不在範圍 |

---

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

| 需求 | 關係 |
|---|---|
| [FR-122](../FR-122-2609-ai-gateway-and-sandbox/) | 沙箱容器、`SandboxClient`、背景工作、AI 閘道全部直接複用 |
| FR-056／FR-058.7 | 檢測工具目錄、派工、結果轉證據、源碼上傳限額與檢查器 |
| [FR-062](../FR-062-2608-license-management/) | 授權歸檢測工具包；能力點落 `detection` 模組 |
| [FR-065](../FR-065-2608-onprem-installer/) | 安裝包收更新包、裝機自動放入、磁碟需求 |
| FR-127／[FR-128](../FR-128-2610-image-zero-critical/) | 出貨 SBOM 管線的指令組合與白名單格式搬進產品；工具進 image 後閘會掃到 |
| FR-064 | 更新包簽章沿用防竄改的鑰與驗章碼路 |
| [FR-132](../FR-132-2610-access-management/) | 三段式能力點與 shipped-roles |
| FR-129.2 | 租戶表 RLS 形狀與 FE 新頁樣板 |
