# FR-177 C1 掃描報告：SonarQube connector（單檔 1,711 行）

> ⚠️ **歷史紀錄**：本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線（修正卡 CM-3559～3564、3573～3576、3584，驗收紀錄見母卡 CM-3507）。文中的檔名與行號是掃描當下（commit `bab2aa3`）的版本，修正後已變動，請勿拿來定位現行程式。

## 一句話結論

**沒有新的高風險問題。** 工具找到 1 個問題（中）：`repo_url` 沒過授權目標清冊。這條和已登記的總表 #224（B5 F2）是同一件事，**不重報**，只是從 connector 內部再確認一次。人工逐條查了卡上 7 個重點，沒有新的高風險，但補到 3 個值得記下的小面向（都是低）：

1. 源碼包裡的 `sonar-project.properties` 沒被刪掉，違反本 repo 規則 SR-04.10；
2. 逾時或取消時只殺主行程，沒殺整個 process group；
3. pull 模式的 `project_key` 沒限制只能讀 Agent 自己建的專案。

## 這一棒在檢查什麼

Agent 代客戶跑 SonarQube 有三種模式：
- **scan**：從 git 拉源碼；
- **upload**：用上傳的源碼包；
- **pull**：只讀 SonarQube 上已有的結果。

本棒看 connector 拿到參數之後有沒有再過濾，以及哪些欄位會進到外部指令（`git clone`、`sonar-scanner`）或 HTTP 請求。

範圍只有 `guidant_agent/scan/adapters/connectors/sonarqube.py` 這 1 檔。為了查證，翻了範圍外的 `guidant_agent/shared/archive_safety.py`（只為查證、不當發現）。

## 總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1（＝#224，不重報） | `repo_url` 只檢查開頭是不是 http/https，沒過授權目標清冊 | Agent 被當跳板，去 `git clone` 內網任意主機；git 的錯誤原文最多 500 字回給使用者，可以拿來探測內網 | 能派 SonarQube scan 任務（雲端或主控台 `run-local-scan`）、Agent 主機裝有 git | `sonarqube.py:836-838`（檢查在 `:430-443`） | 把 `repo_url` 納入清冊檢查，或設允許的 Git 主機清單；clone 加 `-c http.followRedirects=false` | 中 |
| M1（人工） | 源碼包或 repo 裡的 `sonar-project.properties` 沒刪 | 包內設定可以改 `sonar.exclusions`／`inclusions`／`scm.*`，讓掃描少掃一塊，報告看起來乾淨 | 能提供源碼的人（本來就控制源碼） | `sonarqube.py:950-970`、`:1054-1060` | 解壓或 clone 後刪掉該檔，或命令列明示 `exclusions=` 等關鍵參數 | 低 |
| M2（人工） | 逾時或取消時只對主行程送 SIGTERM／SIGKILL | sonar-scanner 若由殼層腳本再開 JVM，JVM 可能變成孤兒繼續吃 CPU 和記憶體 | 掃描逾時或被取消 | `sonarqube.py:1180`（`Popen` 沒設 `start_new_session`）、`_terminate` | `start_new_session=True` 加 `os.killpg`（記得容器 PID 1 的限制） | 低 |
| M3（人工） | pull 模式的 `project_key` 是使用者填的，沒有限制範圍 | 能讀到客戶 SonarQube 上 token 看得到的**其他專案**結果，寫進證據報告 | 能派 pull 任務；範圍由 token 權限決定 | `sonarqube.py:424-428` | 改成產品決策：限制 `Guidant-AI-` 前綴，或要求客戶給最小權限 token | 低 |

## 每條詳述

### F1 — `repo_url` 不過清冊（＝總表 #224）

**白話：** 使用者填的 Git 位址只檢查開頭是不是 `http://` 或 `https://`（`:438`），然後直接交給 `git clone`（`:836-838`）。「只准連哪些主機」的授權目標清冊（`allowed_targets.iter_targets`）只看 `target`／`urls` 這類欄位，不看 `repo_url`。

**實際拼出的指令：**

```
git -c credential.helper= clone --depth 1 --quiet <repo_url> <workspace>/source
```

`env` 為 `{**os.environ, "GIT_TERMINAL_PROMPT": "0"}`。

三位檢查員 3:0 成立。已登記 #224，修法照那條走。

### M1 — `sonar-project.properties` 沒刪

**白話：** sonar-scanner 會讀源碼目錄裡的這支設定檔。命令列已經明示 `sonar.host.url`、`projectKey`、`projectBaseDir`、`sources`，命令列的 `-D` 參數優先於檔案內容，所以**沒辦法用它把 token 送到別的伺服器**（卡上擔心的那條不成立）。但 `exclusions`／`inclusions`／`scm.*` 沒有鎖死。

**為什麼是低：** 工具面板 3:0 判不成立，理由是能放這支檔的人本來就控制源碼。但這違反本 repo `.claude/rules/security/external-calls-and-tools.md` 的明文規則（關鍵參數命令列鎖死，或掃描前刪掉包內設定檔）。對稽核來說，「客戶自己把漏洞藏起來」是真實的動機，所以仍記為低。

### M2 — 沒殺整個 process group

`Popen(command, stdout=..., stderr=..., env=env)` 沒有 `start_new_session`，`_terminate` 用的是 `process.terminate()`／`kill()`。官方 `sonar-scanner` 啟動腳本最後通常用 `exec java`，同一個 PID 就殺得到。所以這條只在打包版本不同時才會成立，**未實機驗證**。

### M3 — pull 模式可讀其他專案

scan／upload 模式的 key 都強制加 `Guidant-AI-` 前綴並走字元白名單（`:445-463`），pull 模式不加。這比較接近設計取捨，交首腦判斷要不要開卡。

## 重點逐條人工查證

| 卡上重點 | 人工結果 |
|---|---|
| `:836-842` git clone：`ext::`、`file://`、`-u` 開頭、`--upload-pack`、`--`、ssh、env | **不成立（除 #224）。** 開頭必須是 http(s)，所以 `ext::`、`file://`、`ssh://`、`git@`、`-` 開頭都進不來。少一個 `--` 也不會被當成選項；`--upload-pack` 只能從選項位置進，URL 位置不行。http(s) 不走 SSH，不會帶出 SSH 金鑰。`GIT_SSH_COMMAND`、`GIT_CONFIG_*` 沒清，但它們只來自 Agent 自己的環境，使用者控制不到。剩下的是 redirect：git 預設跟第一次 redirect，已含在 #224 的修法 |
| `:1054-1064` `-D` 參數被當 Java properties 解析、`project_key` 白名單、源碼包內 `sonar-project.properties` | **部分成立，見 M1。** `-D` 是 argv 的一個元素，Java 當成系統屬性的鍵值直接用，不會解析 `\u` 或換行；scan／upload 的 key 有白名單。branch 不進 scanner（只用在 pull 的 HTTP 參數，httpx 會編碼）。包內設定檔改不了 host.url，但改得了 exclusions，見 M1 |
| `:1064` 繼承整個 `os.environ`；輸出檔有沒有遮 token | **不成立。** 繼承的是 Agent 自己的 env，子行程是 Agent 信任的工具，沒有新的外送路徑。token 走 `SONAR_TOKEN` 環境變數，不進 argv。scanner 不會把 token 印出來；錯誤訊息只取 ERROR 行、最多 1000 字。輸出檔落在 workspace，結束時刪除 |
| `:348-355` httpx：`verify`、redirect、錯誤原文 | **不成立（另見 #232）。** 沒寫 `verify=`，預設就是驗證。`follow_redirects=True`，但 httpx 跨主機 redirect 時會拿掉 `Authorization`，token 不會跟著跳。`base_url` 沒填 scheme 時預設補 `http://`（`:336`），面板 3:0 判為管理員設定，不算漏洞。錯誤訊息帶 server 原話，是 SonarQube 的 msg，不含 token |
| `:475-492` 源碼包：`archive_safety`、symlink、workspace 刪除 | **不成立。** 解壓走 `archive_safety`：tar 用 `filter="data"`，zip 有路徑消毒，逃逸型 symlink 被擋（tar 計數 DoS 同 #226）。workspace 由 `tempfile.mkdtemp` 建、`finally` 用 `rmtree` 刪（`:767-781`）。本機上傳檔（`keep_archive=True`）刻意保留到保留期滿，屬設計 |
| `:1162-1221` `_run_with_cancel` 殺不殺得到整組 | **可能成立，見 M2（低）** |
| pull 模式讀其他專案 | **成立但屬設計取捨，見 M3（低）** |

## 可信度兩層

- **工具層：** stamp 為 `verified`。3 個候選，9 張票。F1 3:0 成立；另兩條（包內設定檔、http 預設）各 0:3 被否決。研究員派出 2 位、回來 2 位，沒有被看門狗砍（`journal.jsonl` 零 `failed`）。
- **人工層：** 卡上 7 個重點逐條開檔看過（上表）。M1～M3 是人工補的，沒有經過面板投票，也沒有實機執行。

## 執行概況（照 stamp 抄）

- run ID：`wf_e0943356-2d7`，effort `low`，focus `attack-surface`，revision `3d5aa2a`
- `verification`：`status=verified`、`candidates=3`、`candidates_deduped=3`、`panel_votes=9`、`panel_reviewed_findings=1`、`panel_quorum_findings=1`、`unreviewed_candidate_sites=0`、`incomplete_panel_candidates=0`、`researchers_dispatched=2`、`researchers_returned=2`、`reason=null`
- 耗時約 28 分鐘
- 歸檔：`docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261106-025642/`
