---
title: "第三方元件掃描搬到 Agent、弱點資料自動同步 — 設計文件 (FR-156)"
brand: "Guidant AI · **FR-156** 第三方元件掃描 Agent 端化"
eyebrow: "FR-156 · 設計文件 v1 · 2026-10-21（D1～D12 全數定案）"
h1: "客戶程式碼不進主系統，弱點資料天天自己更新"
subtitle: "掃描執行位置從主系統沙箱搬到 Agent；弱點資料三段式供應鏈：我們每日發布 → 主系統自抓 → Agent 跟著同步"
lede: "顧問在精靈多選一台 Agent，原始碼從瀏覽器直接傳到那台機器、不經主系統；Agent 本機跑 Syft／Trivy／Grype／OSV，回主系統的只有元件清單與弱點結果。弱點資料由 188 每日排程產包、License Center 蓋章、放 Cloudflare R2，主系統每天自抓，Agent 經心跳得知後自取。本文件把 12 項決策展開到「照做就能交」的程度，拆成七個子需求、12 張實作卡；FR-134 尚未進版，伺服器端路徑整條收掉。"
chips: [
  {text: "D1～D12 全數定案", kind: ok},
  {text: "12 張實作卡、5 批", kind: accent},
  {text: "跨 4 repo：BE／FE／jedi-python-package／evidence-agent", kind: accent},
  {text: "沙箱回到只拆檔，容器數不變", kind: ok},
  {text: "陷阱：Agent 工具 bundle 全 amd64", kind: crit},
  {text: "陷阱：公鑰表只認 189 正式鑰，開發鑰簽的包 Agent 會拒", kind: crit}
]
footer: "FR-156 · 設計文件 · 2026-10-21 · 討論稿：[discussion.html](discussion.html) · 雛形：[prototype/index.html](prototype/index.html) · 姊妹案：[FR-157](../FR-157-2610-agent-local-settings-console/) · 沿革見 LOG 與 git log"
---

> 狀態：**設計定案（D1～D12），已開卡待派**｜日期：2026-10-21｜母卡 [CM-2989](https://app.notion.com/p/FR-156-Agent-400346da4cd0817484eac164228c91cd)、子卡 CM-2990～CM-3001｜前作：FR-134（功能完成未進版）、FR-039／056／058.7／059.3／066
> 討論稿（PM 意見、選項推演）：[`discussion.html`](./discussion.html)｜雛形：[`prototype/index.html`](./prototype/index.html)（五幕）
> 開發 branch：四個 repo 以開工當下 current branch 為準（runner 自己 `git branch --show-current`，不對就停下問）

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

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

| 日期 | 變更 | 對應 |
|---|---|---|
| 2026-10-21 | 初版設計定案（D1～D12），拆 7 子需求 12 張實作卡 | 母卡 CM-2989，子卡 CM-2990～CM-3001 |

---

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

PM 判定**掃客戶程式碼的工具不能預設放在我們的主系統**，而 FR-134 做出來的第三方元件掃描正是顧問把客戶原始碼壓縮檔上傳到主系統、由主系統沙箱掃。同時弱點資料更新現在要人手（裝機帶一份、管理員上傳、或按「檢查更新」），PM 判定弱點更新太快不能靠人。

本案兩件事：**執行位置搬 Agent**、**弱點資料全自動**。第三件事（工具帳密搬 Agent 端）獨立成 FR-157。

### 搬完之後

```{.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 主系統 api
    participant AG as Agent 8444（瀏覽器門）
    participant AGC as Agent 心跳
    participant DB as 主系統 DB
    U->>API: POST /source-scan/targets/{uid}/scans（agent_uid、source_type）
    API->>DB: 建 source_scans（status=awaiting_upload）
    API-->>U: 回 scan_uid ＋ upload_ticket（5 分鐘、綁 agent＋scan）
    U->>AG: PUT /scan-input/{scan_uid}（Bearer ticket，檔案串流）
    AG->>AG: 落 /data/scan-input/{scan_uid}/，算 sha256
    AG-->>U: 回 {size_bytes, sha256}
    U->>API: POST /source-scan/scans/{uid}/start（sha256）
    API->>DB: source_scans→queued；建 agent_tasks（type=sbom_scan）
    AGC->>API: 心跳 → pending_tasks
    AGC->>AGC: 安全解壓 → syft → trivy／grype／osv（讀本機 vulndb/current）
    AGC->>API: POST /agents/tasks/{uid}/result（SBOM 多格式＋三家原始 JSON＋vulndb_date）
    API->>API: 正規化（既有 normalize/）→ 六區塊統計
    API->>DB: components／findings／summary（done）
    U->>API: 看結果、匯出、歷次比對（不變）
```

**守住的邊界**：主系統從頭到尾沒有碰過原始碼。瀏覽器開發者工具看得到上傳請求目的地是 Agent 的 IP。

---

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

整案一句話：**顧問多選一台 Agent，程式碼直接傳到那台機器上掃；弱點資料我們每天發、主系統自己抓、Agent 自己來拿，沒有人要按任何按鈕。**

| 子需求 | 做什麼 | 產出 | 完成怎麼判定（決策者檢查法） |
|---|---|---|---|
| **A 每日發布鏈** | GitLab 排程每日 03:00 在 188 runner 跑：產包→LC 蓋章→上傳 R2→更新 `latest.json`；保留 30 天 | CI job、R2 bucket、`vulndb.jedicotech.com` | 隔天開瀏覽器打 `https://vulndb.jedicotech.com/latest.json`，`bundle_date` 是昨天；pipeline 歷史每天一條綠 |
| **B 主系統自動抓** | 更新站預設指 A 的網址、每日自動預設開、切換後刪舊版、連不到站黃字不彈錯 | config 預設值、`vulndb_update_service` 三處改、設定頁文案 | 連網 DEV 不按任何鍵，隔天設定頁日期自己前進、紀錄多一列「自動下載」；`ls /opt/vulndb` 只有一個日期目錄 |
| **C Agent 同步** | 心跳回應帶雲端現行弱點資料 `{bundle_date, sha256, size_bytes}`；Agent 比對較舊就走控制面 GET 下載、驗章、原子切換、刪舊；心跳回報 `vulndb_date`；Agent 清單與設定頁顯示 | 心跳契約、控制面端點、Agent `vulndb_sync` 模組、`remote_agents.vulndb_date`、兩頁 UI | 主系統更新後 Agent 清單那台的「弱點資料」欄五分鐘內跟上；拔 Agent 機外網線照樣跟上；磁碟不足那台清單顯示原因 |
| **D Agent 掃描執行器** | 第七支 connector `sbom_scan`：取檔→安全解壓→syft→三家離線掃→回傳；tools-manifest 加四列；能力宣告 `sbom_scan` | connector、manifest 四列、capability | 派一筆掃描，Agent 機 `ps` 看到 syft／trivy；主系統結果頁數字與 FR-134 伺服器端掃同一包一致 |
| **E 源碼直傳 Agent** | 主系統簽上傳票；Agent 開 8444 單向 TLS 門收 `PUT /scan-input`；精靈掃描來源頁加「選 Agent」、上傳改目的地；掃描 job 拆派工半／結果半 | 票簽發、Agent 新 listener、精靈改版、job 拆分 | 瀏覽器 Network 面板上傳目的地是 Agent IP；主系統 SeaweedFS 找不到該 zip；A 台的票拿去 B 台回 403 |
| **F 收掉伺服器端** | 沙箱 image 拔四支工具與 `/v1/scan`；`server_side_dispatch` 退役；工具目錄列 `execution_target` 改 `agent`；任務頁出現 Agent 指派欄；FR-058.7 SonarQube 源碼上傳一起改直傳 | image 瘦身、分岔刪除、migration、FE | 沙箱容器 `which syft` 找不到；建檢測工具任務選「SBOM 弱點掃描」出現 Agent 指派欄；SonarQube 任務執行時上傳目的地也是 Agent |
| **G 收尾** | SPEC 三頁、手冊、安裝文件磁碟需求 | 文件 | 手冊「弱點資料」段主路徑寫「不用做任何事」；安裝需求多 7GB |

依賴：A → B → C 串行；D 與 A 平行；E 要 D；F 要 E 驗過；G 最後。

---

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

| # | 決策 | 定案 | 理由 | 被排除的方案 |
|---|---|---|---|---|
| **D1** | SBOM 與弱點結果留雲端？ | **留** | 不留就沒有報表、歷次比對、任務證據 | 全部留 Agent（主系統只存連結） |
| **D2** | 主系統沙箱掃描保留當選項？ | **整個拔掉** | 兩套執行位置兩套弱點資料投遞，維護翻倍；沙箱不再被出貨弱點閘掃三支工具 | 保留為「沒有 Agent 時的退路」 |
| **D3** | Agent 弱點資料雲端中繼還是直連？ | **雲端中繼** | 守 FR-039「Agent 只連雲端」；封閉網路同一條路 | Agent 直連發布站 |
| **D4** | Agent 程式自動升級／過舊提示？ | **都不做** | 決策者裁「先做弱點掃描庫自動更新」；工具版本換等於檢測行為換，釘版隨 Agent 發版 | 過舊提示；一鍵遠端升級 |
| **D5** | 工具帳密搬 Agent、多台各設一次？ | **可做，併 FR-157** | 一個網段通常一台 Agent，或資料台與掃描台分開，各設正常 | 雲端設一次同步各台（會經雲端，與動機矛盾） |
| **D6** | 產包／簽章／發布在哪 | **188 GitLab runner 排程產包、LC 蓋章、Cloudflare R2 發布** | CI 跑的地方就是 188（既有 `.gitlab-ci.yml` 模型）；LC `vulndb_manifest` 型已做好、產包腳本已接；R2 出站零費、與官網同帳號 | Cloudflare 自己排程（抓數 GB 跑 docker 做不到）；188 自持鑰簽（產品端要多信一把鑰） |
| **D7** | 每台 Agent 多約 5GB？ | **先不管** | 只做切換後刪舊版（零成本） | 拿掉 Grype 降到 1.8GB |
| **D8** | 瀏覽器怎麼直傳到 Agent（8443 強制驗客戶端憑證） | **另開 8444 單向 TLS 門，進門帶主系統簽的五分鐘上傳票** | 源碼真不經主系統；Agent 零新信任關係（註冊時已有雲端 JWT 公鑰）；FR-157 登入共用同一張票同一個門 | 經主系統串流 relay（源碼「經過」我們）；8443 放寬（TLS 層做不到按路徑放行） |
| **D9** | Agent 自簽憑證瀏覽器警告 | **第一版接受** | 畫面提示＋手冊教匯入 CA；替代（客戶自備正式憑證）列第二期 | 第一版就做憑證替換 |
| **D10** | R2 公開網域與主系統預設 | **`vulndb.jedicotech.com`，寫進 config 預設值** | 現況 `VULNDB_UPDATE_URL` 預設空字串＝不連網，裝完就是舊資料 | 留空讓客戶自填 |
| **D11** | Agent 弱點資料拉還是推 | **拉** | Agent 永遠是發起方，與 FR-039／059.3 一致；推要雲端主動連 Agent、處理離線重試 | 雲端推到 Agent 資料面 |
| **D12** | FR-058.7 SonarQube 源碼上傳一起改直傳？ | **一起改** | 同一條「客戶程式碼不進主系統」理由完全成立；不改則主系統儲存仍躺著源碼包 | 只改 SBOM 掃描 |

::: {.callout .decided}
**UI 設計約束（沿用 FR-134）**

版面照 Guidant 設計系統自行設計，不仿競品；**沒做的功能不在畫面預告**（Agent 自動升級不出現任何「有新版」字樣）；`grep border-left` 為零。
:::

---

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

| 元件 | 現況座標 | 現況 | 本案動作 | 卡 |
|---|---|---|---|---|
| Agent 執行器框架 | `evidence-agent/core/task_executor.py`、`task_executor_connectors/__init__.py:68 _CONNECTOR_BUILDERS` | 六支 connector；`sonarqube.py:664 _run_scan` 已是「下載源碼包→`_extract_archive`→跑 CLI→回報」 | 加第七支 `sbom_scan.py`，解壓與限額段抽成共用函式與 sonarqube 共用 | D2 |
| Agent 工具 bundle | `evidence-agent/scripts/build/tools-manifest.txt`、`fetch_bundle_tools.sh` | 釘版＋sha256，啟動驗章；全 amd64 | 加 syft 1.54.0／trivy 0.75.0／grype 0.120.0／osv-scanner 2.6.0 四列（版本與沙箱 Dockerfile:89-92 同） | D1 |
| Agent 能力宣告 | `evidence-agent/common/util/agent_capabilities.py` | `file_storage`、`detection_scan` | 加 `sbom_scan`；雲端 `get_agents_with_capability` 多一個值 | D1 |
| Agent 資料面 | `evidence-agent/core/data_plane_server.py`（8443 mTLS，`AGENT_DATA_PLANE_PORT`） | 單一 listener，`ssl_verify_client` 等價 | **新增第二個 listener 8444**（`AGENT_BROWSER_PORT`），同一張伺服器憑證、不驗客戶端憑證、只掛 `/scan-input`；compose 多發布一個埠 | E1 |
| Agent JWT 驗證 | `evidence-agent/core/agent_auth.py:102 verify_data_plane_jwt` | 用註冊時取得的 `jwt_public.pem` 驗雲端簽的資料面 token | 加 `verify_upload_ticket`（同公鑰，claims 不同） | E1 |
| Agent 心跳 observer | `evidence-agent/core/enroll.py:322-335`（`_observe_error_tracking`、`_observe_allowed_targets`） | 兩個 observer 吃心跳回應 `data` | 加 `_observe_vulndb` | C2 |
| Agent 控制面下載 | `evidence-agent/core/task_executor.py:183 _get_stream`、`core/profile_cache.py` | FR-059.3 profile 投遞：串流下載＋sha256 對帳＋原子 rename | `vulndb_sync.py` 照抄 pattern | C2 |
| Agent 驗章 | `evidence-agent/common/integrity/verify.py:54 verify_payload(expected_type)`、`public_keys.py` | 認 `agent_manifest` 型 | 加認 `vulndb_manifest` 型（常數值與 BE `common/integrity/adapters.py:58` 同） | C2 |
| 雲端心跳服務 | `jedi_remote_agent/app/service/agent_enrollment_service.py:449 _heartbeat` | 回 `pending_tasks`、`error_tracking`、`allowed_targets` | 回應加 `vulndb`；payload 收 `vulndb_date` 寫 `remote_agents.vulndb_date` | C1 |
| 雲端弱點資料服務 | `jedi_source_scan/app/service/vulndb/vulndb_update_service.py`（`apply`／`status`／`_switch`／`_prune`） | 驗章→日期不倒退→原子切換；`_prune` 保留舊版 | `_prune` 改刪舊；加 `current_bundle_info()` 給心跳用；加控制面串流端點 | B1、C1 |
| 雲端自動更新 | `api/source_scan/routes/vulndb_route.py:81-99`、`config/config.py:239 VULNDB_UPDATE_URL` | 檢查更新、每日開關；URL 預設空 | URL 預設 D10 網址；開關預設 true；連不到站紀錄一列 `status=skipped` | B1 |
| 雲端掃描 job | `jedi_source_scan/app/jobs/sbom_scan_job.py`（吃 `ISandboxScanner`） | 取檔→送沙箱→存產物→正規化→寫表 | 拆：派工半建 `agent_tasks`；結果半收 Agent 回傳後走既有正規化；`ISandboxScanner` port 與 impl 刪 | E2 |
| 雲端派工分岔 | `jedi_detection/app/service/server_side_dispatch.py`、`server_side_result.py` | `execution_target='server'` 不派 Agent | 兩檔退役；`execution_target` 欄保留但值全改 `agent` | F1 |
| 工具目錄 | `scripts/sql/2026-10-10-fr134-h1-sbom-scan-tool.sql` | `sbom-scan` 列 `execution_target='server'`、`connection_type NULL` | migration 改 `agent`；FE 任務頁去掉「伺服器端不顯示 Agent 欄」分支 | F1 |
| 源碼上傳（FR-058.7） | `jedi_detection/common/detection_source_file.py`（`_source_file` 契約）、FE 執行時上傳元件 | 上傳到主系統 `upload_files`，agent 走 `GET /agents/files/{uid}` 取 | 改：上傳直傳 Agent 8444，`_source_file` 改帶 `{agent_local_uid, sha256, size_bytes, file_name}`；agent 端 `_download` 改讀本機 | F2 |
| 精靈 | FE `src/views/source-scan/WizardStepSource.vue` | 兩張來源卡＋上傳框 | 上方加「在哪台 Agent 上掃描」單選（只有一台自動選不顯示）；上傳框下加直傳提示；上傳目的地改 Agent | E2 |
| Agent 清單 | FE `src/views/remote-agent/RemoteAgentManage.vue:524-` | 名稱／位址／狀態／能力／版本 | 加「弱點資料」「最後同步」兩欄＋兩張統計卡 | C1 |
| 設定頁弱點資料 | FE `src/views/vulndb/VulndbManage.vue` | 日期、來源五項、上傳、檢查更新、開關、紀錄 | 加「Agent 同步狀態」表；文案改「由 Guidant AI 每日發布」 | C1 |
| 沙箱 image | `jedi-evidence-classification/docker/Dockerfile:89-101`、`classifier_service.py /v1/scan` | 含四支工具與掃描端點 | 拔掉，回到只拆檔；compose x-anchor 的 vulndb volume 掛載移除 | F1 |
| CI | BE `.gitlab-ci.yml`（web／api 觸發，188 runner） | 四 stage 出版 pipeline | 加獨立 `vulndb-publish` job，`rules: $CI_PIPELINE_SOURCE == "schedule"`，與出版 pipeline 互斥 | A1 |

---

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

### 5.1 A 每日發布鏈

```{.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
  subgraph JT["我們（每日 03:00，GitLab schedule → 188 runner）"]
    UP["上游：Trivy DB／Grype DB／OSV／KEV／EPSS"] --> B1["build_vulndb_bundle.sh（既有）"]
    B1 --> LC["License Center 簽 vulndb_manifest（既有）"]
    LC --> R2["R2：guidant-vulndb-YYYYMMDD.tar.zst<br/>latest.json"]
  end
  subgraph CUST["客戶（連網）"]
    R2 -->|"每日 GET latest.json"| API["主系統 worker：驗章→volume→刪舊→紀錄"]
    API -->|"心跳回應 vulndb{date,sha256}"| AG["Agent：較舊就 GET bundle→驗章→切換→刪舊"]
  end
  subgraph CLOSED["客戶（封閉）"]
    ADM["管理員上傳更新包（既有）"] --> API2["主系統"] -->|同一條心跳| AG2["Agent"]
  end
```

**CI job**（BE `.gitlab-ci.yml` 新增，獨立於出版四 stage）：

```yaml
vulndb-publish:
  stage: build
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule" && $VULNDB_PUBLISH == "true"
  script:
    - scripts/build/build_vulndb_bundle.sh --out .build/vulndb      # 既有，含送 LC 簽
    - scripts/build/publish_vulndb_bundle.sh .build/vulndb          # 新：上傳 R2＋寫 latest.json＋清 30 天外
```

- GitLab 排程：每日 03:00 Asia/Taipei，變數 `VULNDB_PUBLISH=true`；出版 pipeline 的 `workflow.rules` 加一條 `schedule` 放行但其他 job 以 `$VULNDB_PUBLISH` 排除。
- `publish_vulndb_bundle.sh`（新）：用 `rclone` 或 `aws s3`（S3 相容）上傳到 R2 bucket `guidant-vulndb`；憑證走 CI 變數 `R2_ACCESS_KEY_ID`／`R2_SECRET_ACCESS_KEY`／`R2_ENDPOINT`（**新 env var，先 grep 確認無同義**；值不入版控）；上傳完才覆寫 `latest.json`（先包後索引，避免索引指到半個包）；刪 30 天前的包。
- **`latest.json` 契約**（凍結，產品端 `check_update` 消費）：

```json
{"bundle_date":"2026-10-20","file":"guidant-vulndb-20261020.tar.zst","sha256":"…","size_bytes":503316480,
 "sources":{"trivy":{"date":"2026-10-20","count":312480},"grype":{…},"osv":{…},"kev":{…},"epss":{…}},
 "tool_versions":{"syft":"1.54.0","trivy":"0.75.0","grype":"0.120.0","osv-scanner":"2.6.0"}}
```

- 自訂網域 `vulndb.jedicotech.com` 綁 bucket（Cloudflare 同帳號）；bucket 公開讀。
- 失敗可見性：pipeline 紅走既有通知；主系統連三天沒抓到新包，設定頁黃字「更新站 3 天無新資料」。
- **鑰**：一律正式鑰（kid `f3b562a6d9755527`，FR-134 §5.2）。開發鑰簽的包產品與 Agent 都會拒收（FR-134 已踩）。

### 5.2 B 主系統自動抓

改三處，皆在既有碼內：

1. `config/config.py:239` `VULNDB_UPDATE_URL` 預設改 `https://vulndb.jedicotech.com`；`.env` sample 與 `deployment-env.md` 同步補一行。
2. `vulndb_update_service` 的每日開關預設 `true`（存在 `system_config` 的那筆初值；migration 補 seed）；`check_update` 連不到站時寫 `vulndb_updates` 一列 `status=skipped`、`error=無法連線更新站`，**不拋錯不寄信**；設定頁最近一列是 skipped 時顯示黃字並附「封閉網路請改用上傳更新包」。
3. `_prune(keep)` 改為刪除所有非 `keep` 的日期目錄（現況保留）。進行中的掃描已不在主系統跑（D2），刪舊無競態。

### 5.3 C Agent 同步

**心跳契約擴充**（`jedi_remote_agent` 心跳回應 `data`）：

```json
"vulndb": {"bundle_date": "2026-10-20", "sha256": "<tar.zst 的 sha256>", "size_bytes": 503316480}
```

無弱點資料時 `vulndb: null`。心跳 payload（Agent → 雲端）加 `vulndb_date`（字串或 null）與 `vulndb_sync_error`（字串或 null，例 `磁碟不足：剩 2.1GB，需 6GB`）。雲端寫 `remote_agents.vulndb_date`、`remote_agents.vulndb_sync_error`（migration 兩欄）。**沒帶就不動現值**（與 `capabilities` 相容契約同款）。

**控制面端點**（`jedi_remote_agent` 或 `jedi_source_scan` 掛在 agents blueprint）：`GET /api/1.0/agents/vulndb/bundle` → 通行證驗身 → 串流回 `current` 對應的 `.tar.zst`（主系統 volume 要保留原包，`_switch` 時順便把包留在 `<date>/bundle.tar.zst`）。回 `X-Bundle-Date`、`X-Bundle-Sha256` 標頭。

**Agent 端 `core/vulndb_sync.py`**（新，照 `profile_cache.py` pattern）：

| 步驟 | 做法 |
|---|---|
| 觸發 | `_observe_vulndb(cfg, data, state)`：比對 `data.vulndb.bundle_date` 與本機 `/data/vulndb/current/manifest.json` 的 `bundle_date`；較新且沒有同步在跑才起背景 thread（單飛鎖） |
| 磁碟 | `shutil.disk_usage("/data/vulndb")` 要 ≥ `size_bytes × 10`（解開約 10 倍）＋1GB；不足→記 `vulndb_sync_error`，下次心跳回報，不下載 |
| 下載 | `_get_stream("/agents/vulndb/bundle", "/data/vulndb/.incoming/<date>.tar.zst", expected_sha256=…)`（既有，含對帳） |
| 驗章 | 解到 `.staging/<date>/`，`verify_payload(manifest.sig, expected_type="vulndb_manifest")`，逐檔 sha256 對 `manifest.json`，`bundle_date` 不得早於現行 |
| 切換 | `os.replace` 暫存目錄到 `/data/vulndb/<date>/`，`current` symlink 原子換（`os.symlink` 到暫名再 `os.replace`），刪其他日期目錄與 `.incoming` |
| 掃描中 | connector 開跑時先 `os.path.realpath(current)` 鎖定實路徑，切換不影響進行中掃描；刪舊目錄只刪「沒有進行中掃描引用」的（簡單做法：刪前檢查 `_running_scans` 計數，有就延到下次心跳） |
| 回報 | 下次心跳 `vulndb_date` 帶新日期、`vulndb_sync_error` 清空 |

**Agent 磁碟佈局**：`/data/vulndb/{<date>/, current -> <date>, .incoming/, .staging/}`，compose named volume `agent-vulndb`，原生安裝版 `/var/lib/guidant-agent/vulndb`。

**UI**：Agent 清單加「弱點資料」（日期＋距今 tag：≤30 綠、31～90 黃、>90 紅、無則「尚未同步」＋原因）、「最後同步」；上方統計卡「弱點資料已同步 n/N」「主系統弱點資料 日期」。設定頁弱點資料加「Agent 同步狀態」表（雛形幕 3、4）。

### 5.4 D Agent 掃描執行器

**`core/task_executor_connectors/sbom_scan.py`**（新）：

| 項目 | 設計 |
|---|---|
| 註冊 | `_CONNECTOR_BUILDERS["sbom-scan"]`；capability `sbom_scan` |
| 輸入 | `params._source_file = {agent_local_uid, sha256, size_bytes, file_name, source_type(upload|sbom), limits}`；`params.tools = ["trivy","grype","osv"]`（使用者勾選）；`params.sbom_formats`（既有 FR-134 D5 清單） |
| 取檔 | 從 `/data/scan-input/<scan_uid>/` 讀（E1 落的），比 sha256；不存在→fail「上傳未完成或已過期」 |
| 解壓 | 抽 sonarqube 的 `_extract_archive`＋限額到 `task_executor_connectors/_archive.py` 共用（D13 精神：借檢查器）；SBOM 檔不解壓直接用 |
| 工具 | `syft scan dir:<src> -o cyclonedx-json=…,spdx-json=…`（多格式一次）；`trivy sbom --skip-db-update --offline-scan --cache-dir <vulndb>/trivy -f json`；`GRYPE_DB_AUTO_UPDATE=false GRYPE_DB_CACHE_DIR=<vulndb>/grype grype sbom:… -o json`；`osv-scanner --offline --local-db-path <vulndb>/osv --sbom … --format json`。工具路徑走 `resource_path()` 慣例（FR-066） |
| 進度 | 四段 `ctx.report_progress`（接收檔案→產出物料清單→比對弱點→整理報告）映射到既有 `agent_tasks` 進度回報 |
| 輸出 | `ScanResult` 多檔：SBOM 各格式、三家原始 JSON、`tool_versions`、`vulndb_date`（讀 manifest）、`coverage`（缺 lock 清單，FR-134 D3）；上傳走既有 `_upload_reports`（檔案進主系統 `upload_files`，與現在沙箱產物落點一致） |
| 清理 | 掃完刪工作區；源碼包保留 `AGENT_SCAN_INPUT_RETENTION_DAYS`（預設 1）後刪，供「重掃沿用」 |
| 資源 | 單次 20 分鐘 timeout、記憶體不設 cgroup（Agent 是整台機），文件寫建議 4GB 可用 |
| 時限內取消 | 沿用 `request_cancel` |

**tools-manifest 四列**：id／版本／sha256／URL 照既有格式；`fetch_bundle_tools.sh` 的「解成目錄樹」對這四支就是放進 `tools/bin/`。sha256 取 GitHub release 的 checksums 檔實值。

### 5.5 E 源碼直傳 Agent

**上傳票**（主系統簽，`jedi_remote_agent` 新 `agent_ticket_service`，用既有資料面 JWT 私鑰 RS256）：

```json
{"iss":"guidant-cloud","aud":"agent:<agent_uid>","sub":"<user login_name>","tenant_id":102,
 "purpose":"scan_upload","scan_uid":"<uid>","max_bytes":104857600,"jti":"<uuid>","exp":<now+300>}
```

- 端點 `POST /source-scan/targets/{uid}/scans`（既有建掃描端點改）回 `{scan_uid, upload: {url: "https://<agent base host>:8444/scan-input/<scan_uid>", ticket}}`；`agent base host` 取 `remote_agents.base_url` 的 host。
- FR-157 的登入票 `purpose:"console_login"`，同一支服務。

**Agent 8444 listener**（`core/browser_plane_server.py` 新，與 `data_plane_server.py` 同款 Flask thread）：

| 項目 | 設計 |
|---|---|
| TLS | 同一張 Agent 伺服器憑證（CA 是雲端），`verify_mode=CERT_NONE`，TLS 1.2+ |
| 埠 | `AGENT_BROWSER_PORT` 預設 8444；compose 多 `ports: 8444:8444`；install.sh 防火牆提示加一行 |
| 端點 | `PUT /scan-input/<scan_uid>`：`Authorization: Bearer <ticket>` → `verify_upload_ticket`（公鑰、`aud` 是自己、`purpose`、`scan_uid` 相符、未過期、`jti` 未用過——記憶體 set＋TTL）→ 串流落 `/data/scan-input/<scan_uid>/<file_name>`，邊寫邊算 sha256、超 `max_bytes` 中斷刪檔 → 回 `{sha256, size_bytes}` |
| CORS | 瀏覽器從主系統網域發 PUT 到 Agent 是跨來源：回 `Access-Control-Allow-Origin: <ticket 內 origin claim>`、處理 `OPTIONS` preflight；票加 `origin` claim（主系統 FE 網址） |
| 不開 | 其他路徑一律 404；不掛 `/blob`、不掛 `/health` |

**精靈**（`WizardStepSource.vue`）：上方加「在哪台 Agent 上掃描」單選（來源 `GET /remote-agents?capability=sbom_scan&online=true`，過濾 `vulndb_date` 非空；一台自動選隱藏區塊；零台顯示「本租戶尚無可掃描的 Agent」並擋下一步）；上傳框下方直傳提示（雛形幕 1）；送出時先建掃描拿票，`PUT` 到票的 url（進度條顯示目的地，雛形幕 2），成功後 `POST /source-scan/scans/{uid}/start` 帶 sha256。CORS／憑證錯誤→白話訊息「無法連到 Agent 192.168.x.x，請確認您在客戶內網並已信任 Agent 憑證」。

**掃描 job 拆分**（`jedi_source_scan`）：

- `source_scans` 加 `agent_uid`、`agent_local_uid`、狀態多 `awaiting_upload`（建了還沒傳完；1 小時沒 start 由既有清理排程改 `failed`）。
- 派工半：`start` → 建 `agent_tasks(type=sbom-scan, params)` → `queued`。
- 結果半：Agent `POST /agents/tasks/{uid}/result` 既有路徑 → `sbom_scan_result_handler`（新，取代 job 的沙箱段）：取回傳檔→既有 `normalize`→寫表→`done`；`vulndb_date` 記 Agent 回報值。
- `ISandboxScanner` port、infra impl、`/v1/scan` client 刪。

### 5.6 F 收掉伺服器端

| 項目 | 動作 |
|---|---|
| 沙箱 Dockerfile | 刪 `:89-101` 四支工具下載層；`classifier_service.py` 刪 `/v1/scan`；image 重出 |
| compose | x-anchor 保留（FR-134 D1 的加固抽象仍有價值），移除 `guidant-scan-vulndb` 掛載到沙箱；volume 仍由 api／worker 用 |
| `server_side_dispatch.py`／`server_side_result.py` | 刪；`detection_orchestration_service` 的 `is_server_side` 分支刪 |
| migration | `UPDATE config.detection_tools SET execution_target='agent' WHERE code='sbom-scan'`；欄位保留（他日真有伺服器端工具再用） |
| FE 任務頁 | 刪「`execution_target==='server'` 不顯示 Agent 欄」分支；抽屜 D10／D18 當時藏掉的 Agent 字樣恢復 |
| FR-058.7 SonarQube（D12） | 執行時上傳元件改走同一條票（`purpose:"scan_upload"`，`scan_uid` 換 `execution_uid`）直傳被指派的 Agent；`_source_file` 契約改帶 `agent_local_uid`；多 Agent 分派（FR-067）每列各自一張票傳到各自 Agent；agent 端 sonarqube `_download` 改讀本機 |
| 守衛測試 | 沙箱守衛不變；加一條「`jedi_source_scan` 不得 import `ISandboxScanner`」防復活 |

### 5.7 安全規則對照

| 規則檔 | 本案落實 |
|---|---|
| 開端點 | 8444 只掛一條 PUT、票驗 `aud`／`purpose`／`jti`／`exp`；控制面 bundle 端點通行證驗身 |
| 上傳解析 | 沿用 FR-058.7 限額與 `_extract_archive`；SBOM 檔 50MB 上限；超 `max_bytes` 中斷 |
| 祕密 | 票用既有 JWT 私鑰；R2 憑證 CI 變數；log 不記票本體 |
| log | 稽核事件：`source_scan.upload_ticket_issued`、`vulndb.agent_synced`（Agent 回報時雲端記）、既有 `vulndb.update` |
| 呼叫外部 | 主系統只連 `VULNDB_UPDATE_URL`；Agent 不出網（守衛：Agent 容器 `dns` 黑洞測試仍過） |

---

## 6. 拆分（12 張實作卡） {#breakdown nav="拆分"}

| 批 | 卡 | 白話 | 改哪些檔（repo） | 驗收 | 依賴 |
|---|---|---|---|---|---|
| 1 | **A1** 每日弱點包發布 pipeline | 讓 188 每天凌晨自己產包、簽章、放到 Cloudflare | BE `.gitlab-ci.yml`、`scripts/build/publish_vulndb_bundle.sh`（新）、`.env` sample、`deployment-env.md` | 手動 run 一次 schedule pipeline 綠；`curl https://vulndb.jedicotech.com/latest.json` 日期正確、sha256 與包一致；R2 只留 30 天內 | 決策者先建 R2 bucket＋網域＋CI 變數 |
| 1 | **D1** Agent 帶四支掃描工具＋能力宣告 | Agent 安裝包多裝四支工具，心跳告訴雲端「我會掃元件」 | evidence-agent `tools-manifest.txt`、`fetch_bundle_tools.sh`、`agent_capabilities.py`、`build_agent_bundle.sh` | 出包後斷網容器 `syft version`／`trivy --version`／`grype version`／`osv-scanner --version` 四支都有；雲端 Agent 列 capabilities 含 `sbom_scan` | — |
| 1 | **D2** Agent `sbom_scan` 執行器 | Agent 收到掃描工單會解壓、跑四支工具、把結果傳回 | evidence-agent `task_executor_connectors/sbom_scan.py`（新）、`_archive.py`（抽共用）、`sonarqube.py`（改用共用）、`__init__.py` | 手放一個 zip 到 `/data/scan-input/<uid>/`、手放弱點資料到 `/data/vulndb/current`，派工單跑完回傳 SBOM 兩格式＋三家 JSON；缺 lock 專案 `coverage` 有列 | D1 |
| 2 | **B1** 主系統自動抓預設開＋刪舊 | 裝好就會每天自己更新，不用人按 | BE `config/config.py`、jedi-source-scan `vulndb_update_service.py`、seed migration、FE `VulndbManage.vue` 文案 | 連網 DEV 隔天紀錄多一列自動下載；斷網 DEV 紀錄一列 skipped 且設定頭黃字；volume 只剩一個日期目錄 | A1 |
| 2 | **C1** 心跳帶弱點資料＋控制面下載端點＋清單欄位 | 雲端告訴每台 Agent 現在的資料日期、給它一條路來拿；畫面看得到每台日期 | jedi-remote-agent（心跳回應／payload、兩欄 migration）、jedi-source-scan（bundle 端點、`current_bundle_info`）、FE `RemoteAgentManage.vue`、`VulndbManage.vue` | 心跳回應 JSON 有 `vulndb`；`curl` 帶通行證打 bundle 端點拿到 tar.zst 且 sha256 對；清單出現兩欄、設定頁出現 Agent 同步表 | B1 |
| 2 | **C2** Agent 弱點資料同步模組 | Agent 發現雲端資料較新就自己下載、驗章、換上、刪舊、回報 | evidence-agent `core/vulndb_sync.py`（新）、`enroll.py` observer、`common/integrity/verify.py` 加型別、compose volume、install.sh 目錄 | 主系統更新後 Agent 五分鐘內 `current` 指新日期、舊目錄消失、清單日期跟上；磁碟不足機器清單顯示原因不下載；改一位元組的包被拒且 `current` 不動 | C1 |
| 3 | **E1** 上傳票＋Agent 8444 瀏覽器門 | 主系統發五分鐘入場券，Agent 多開一道只收檔的門 | jedi-remote-agent `agent_ticket_service.py`（新）、evidence-agent `core/browser_plane_server.py`（新）、`agent_auth.py`、compose、install.sh | `curl -X PUT` 帶票傳檔成功回 sha256；過期票 401；A 台票打 B 台 403；同票二用 409；無票 401；超 `max_bytes` 413 且檔刪 | D2 |
| 3 | **E2** 精靈選 Agent＋直傳＋掃描 job 拆分 | 顧問多選一台 Agent，檔案直接飛到那台；主系統只收結果 | FE `WizardStepSource.vue`、`SourceScanWizard.vue`、`SourceScanService.js`；jedi-source-scan `sbom_scan_job.py` 拆、`sbom_scan_result_handler.py`（新）、`source_scans` 三欄 migration、刪 `ISandboxScanner` | 照雛形幕 1、2 走完一筆；Network 面板目的地是 Agent IP；SeaweedFS 無該 zip；結果頁六區塊數字與同一包在 FR-134 沙箱掃的一致；1 小時沒 start 的列變 failed | E1 |
| 4 | **F1** 收掉伺服器端路徑 | 沙箱不再帶掃描工具，任務頁「SBOM 弱點掃描」變回要選 Agent | jedi-evidence-classification Dockerfile＋`classifier_service.py`、jedi-detection 刪兩檔＋分支、migration、FE 任務頁與抽屜、守衛測試 | 沙箱 `which syft` 無；建任務選 SBOM 弱點掃描出現 Agent 欄；整條鏈（任務→執行→證據）跑通；守衛測試綠且 import `ISandboxScanner` 會紅 | E2 |
| 4 | **F2** SonarQube 源碼上傳改直傳 Agent | SonarQube 掃描的程式碼也不再經主系統 | FE 執行時上傳元件、jedi-detection `detection_source_file.py` 契約、evidence-agent `sonarqube.py` 取檔 | SonarQube 任務執行上傳目的地是 Agent；多 Agent 分派每列各自上傳；舊 `GET /agents/files` 路徑對源碼包不再使用（profile 仍用） | E1 |
| 5 | **G1** SPEC 三頁＋手冊＋安裝文件 | 文件改成現況 | BE `docs/spec-site/current/`（第三方元件風險、Agent 管理、系統設定弱點資料）、`docs/user-manual/`、installer docs | 手冊「弱點資料」主路徑寫不用做任何事；安裝需求 Agent 多 7GB、8444 埠；`grep "上傳更新包"` 只在封閉網路段 | 全部 |
| 5 | **G2** 出貨基線＋收尾紀錄 | 新 migration 進基線、STATE／LOG | BE `scripts/init/`、`handoff/` | 188 基線庫 `schema_migrations` 含本案全部；STATE 只描述此刻 | G1 |

**決策者功課（開工前）**：①建 Cloudflare R2 bucket `guidant-vulndb`、綁 `vulndb.jedicotech.com`、產一組唯寫 API token 填進 GitLab CI 變數；②確認 188 runner 的 GitLab 排程可用（Settings → CI/CD → Schedules）。

---

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

| # | 步驤 | 期望 |
|---|---|---|
| 1 | 在 DEV 手動觸發 schedule pipeline | 綠；`latest.json` 日期＝今天上游資料日期 |
| 2 | DEV 主系統等到隔天 03:00 後（或按「立即檢查更新」） | 設定頁日期前進；紀錄一列自動下載；volume 單目錄 |
| 3 | 兩台 Agent 在線（一台磁碟故意只留 2GB） | 五分鐘內一台日期跟上、一台顯示磁碟不足 |
| 4 | 精靈新增掃描，選第一台，上傳決策者桌面 `guidant-be-sca-test.zip` | 幕 1→2→四段進度→結果頁；Network 目的地 Agent IP；SeaweedFS 無 zip |
| 5 | 同一包用 FR-134 沙箱版（切回舊 commit 的 DEV）掃一次 | 六區塊統計、元件數、弱點數一致（容許 vulndb 日期不同造成的弱點數差，記錄差異原因） |
| 6 | 任務頁建「SBOM 弱點掃描」檢測工具任務 | 出現 Agent 欄；執行→證據→「看完整結果」數字一致 |
| 7 | SonarQube 任務執行上傳 | 目的地 Agent；掃描照常完成 |
| 8 | 拔第二台 Agent 外網線（DNS 黑洞），主系統上傳一包新日期 | 該台仍跟上 |
| 9 | 把 A 台的上傳票拿去 B 台 PUT | 403 |
| 10 | 沙箱容器 `which syft trivy grype osv-scanner` | 四個都無 |

---

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

| 風險 | 怎麼擋 |
|---|---|
| 開發鑰簽的弱點包 Agent 拒收（FR-134 已踩） | CI 一律正式鑰；DEV 要測 C2 就用 CI 產的真包，不手簽 |
| Agent 機磁碟爆 | 下載前 `disk_usage` 檢查、不足不下載並回報；安裝文件寫需求 |
| 瀏覽器 CORS 擋 PUT | 票帶 `origin`，8444 回對應 `Allow-Origin`、處理 preflight；E1 驗收含瀏覧器實測不只 curl |
| 瀏覽器連不到 Agent（顧問在外網） | 錯誤訊息白話；手冊寫明只能在客戶內網操作；這是設計結果不是缺陷 |
| 排程 pipeline 撞人手 build | 既有 `pgrep` 守門；排 03:00 |
| 進行中掃描時切換弱點資料 | connector 開跑鎖實路徑；刪舊延到無進行中掃描 |
| `jti` 防重放只在記憶體，Agent 重啟後失效 | 票只 5 分鐘，重啟窗口可接受；寫進設計不做持久化 |
| FR-067 多 Agent 分派下 SonarQube 每列各自上傳 | F2 驗收明列；FE 元件按分派列各拿一張票 |
| Syft SBOM 帶相對檔案路徑 | 不含內容；SPEC 寫明回雲端的 SBOM 含「套件名、版本、來源檔相對路徑」 |

---

## 9. 這份文件的定位 {#positioning nav="定位"}

| 你想知道 | 看 |
|---|---|
| PM 說什麼、選項怎麼推 | [討論稿](discussion.md) |
| 畫面長什麼樣 | [雛形](prototype/index.html) |
| 每個決策為什麼 | 本文件 §3 |
| 開工要改哪些檔 | 本文件 §4、§6 |
| 帳密搬 Agent 端 | [FR-157](../FR-157-2610-agent-local-settings-console/) |
| 做到哪了 | [README](README.md) 總進度、`handoff/`（開工後） |
