⚠️ 歷史紀錄:本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線(修正卡 CM-3559~3564、3573~3576、3584,驗收紀錄見母卡 CM-3507)。文中的檔名與行號是掃描當下(commit
bab2aa3)的版本,修正後已變動,請勿拿來定位現行程式。
沒有急件。 找到 1 個中風險問題:主控台「檢測工具」頁可以只改工具位址、密碼欄留空,Agent 就會把原本存著的客戶工具密碼(SonarQube token、OpenVAS 密碼、ZAP API key)送到新位址。要打得到,得先有主控台 configure 權限的登入。三個獨立檢查員投票 3 比 0 確認成立。這一條建議排進近期修正,因為它違反了我們自己訂的 SR-06.3(「位址改了,祕密要重填」),而 LDAP 那頁已經擋了。
Agent 是裝在客戶自己機器上的程式。這一塊是:
這裡保管的是客戶自己機器的密碼,漏出去就是客戶受檢主機被拿走。
範圍 14 檔(guidant_agent/scan/app/ 的任務執行與帳密 7 支、scan/adapters/credential_store.py、scan/api/、scan/ports/ 兩支)。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | 主控台改檢測工具的位址時,密碼欄留空就沿用舊密碼,沒有要求重填 | 能進主控台「檢測工具」頁但看不到密碼的人,把位址改成自己的機器、按「測試連線」,就拿到客戶工具的密碼或 token | ①主控台登入且有 configure 權限 ②來源在主控台允許的網段內 ③該工具已存過帳密 ④Agent 連得到攻擊者的機器 |
guidant_agent/console/api/tools.py:66(觸發點);guidant_agent/scan/app/secret_form.py:139、:146(沿用舊值的地方) |
存工具帳密時把位址類欄位(base_url、port)當 pinned_keys 傳進 secret_form.save |
中 |
為什麼是「中」不是「高」: 陌生人打不到,要先有主控台 configure 權限的登入。但設計上這種人「只能看到已設定、看不到密碼」,這條等於繞過那道牆。
白話: 主控台的表單為了不把密碼顯示在畫面上,規定「密碼欄留空=沿用已存的密碼」。secret_form.merged 有一道保護:「位址改了,密碼就要重填」(SR-06.3),但前提是呼叫端傳入 pinned_keys(哪些欄位算位址)。LDAP 那頁有傳,檢測工具這頁沒傳,所以保護從來沒有生效。
出事會怎樣: 攻擊者送 PUT /console/api/tools/sonarqube,內容 {"fields": {"base_url": "https://攻擊者的機器"}},token 留空。接著按 POST /console/api/tools/sonarqube/test,Agent 帶著 Authorization: Bearer <存著的 token> 去連攻擊者的機器。OpenVAS(帳號密碼)、ZAP(API key)一樣。之後主系統派這支工具的掃描,也都會把密碼送過去。另外 base_url 刻意不在授權清冊(allowed_targets)的比對範圍內,所以清冊也擋不到。
要先有什麼才打得到: 主控台(8444 埠)登入、有 configure 權限、來源在允許網段內;該工具已存過帳密;Agent 連得到攻擊者的機器。
在哪裡:
guidant_agent/console/api/tools.py:66:secret_form.save(code, schema, raw),沒帶 pinned_keys(這檔在範圍外,是查證觸發點用,問題本體在範圍內的 secret_form.py);guidant_agent/scan/app/secret_form.py:139:moved 永遠是 False;guidant_agent/scan/app/secret_form.py:146:把舊密碼補進新位址的這筆設定。怎麼修: 工具路由存檔時傳 pinned_keys(固定 base_url、port,或由工具 schema 標出位址類欄位)。更穩的做法:merged 改成預設擋,只要任何非密碼欄位改了,就要求重填密碼,呼叫端明說才放行。補一支回歸測試:改 base_url、token 留空 → 400。
runner 開檔核對: 成立。tools.py:62-68 的 put_tool 只傳了三個位置參數,沒有 pinned_keys;secret_form.py:127-147 的邏輯與描述一致。
| 卡上的點 | 人工結果 | 為什麼 |
|---|---|---|
credential_store 金鑰與密文同目錄,第一次產生金鑰時的權限 |
不成立(設計如此) | 金鑰檔用 os.open(..., O_EXCL, 0o600) 建,目錄 makedirs(mode=0o700),讀時權限不是 600 會記 warning(platform/secret_store.py:94-107,B7 範圍,只為查證)。同目錄的風險是已知的設計取捨:只防「拿到密文檔但沒拿到金鑰」,整台被複製就兩個都拿到。不另報。 |
put 空值保留舊值 + validate 的 default,會不會送空值把密碼清成預設 |
不成立 | validate 第 89 行的 default 只套在非祕密欄(not field.get("secret"))。祕密欄空值在 put 第 73 行被略過、第 76-78 行補回舊值,所以空值是「不改」,不會清成預設。真正的問題是反過來:空值沿用舊值的時候沒檢查位址有沒有變,就是 F1。 |
credential_handover.store 帳密有沒有進 log |
不成立 | audit(...) 只帶 tool=code, method="handover";rejected 的 log(第 46 行)帶的是 problem,來自 validate 的錯誤訊息,內容只有欄名與原因(例如「X 不是這個表單的欄位」「要填數字」),不含值。 |
_clear 能不能清掉所有工具帳密(讓掃描功能停擺) |
成立但屬設計內,不報 | clear=true 的單可以清掉列出的工具(_開頭的保留代碼除外)。但它的來源只有兩個:心跳派工、8443 資料面 DELETE /detection/credentials,都要主系統簽的 JWT。主系統被打穿本來就能做更多事,這屬於主系統的權限,不是 Agent 的漏洞。 |
_run_one 解出來的帳密交給 connector 的方式 |
不成立 | credential_resolver.resolve 回的 dict 是 get_connector(...) 的第三個參數,跟 params 分開,沒有塞進 params。connector port 介面沒有寫明「不准進命令列」,這條規範在 .claude/rules/security/secrets.md(SR-06.4)與 connector 檔頭;各 connector 怎麼交給外部程式不在本棒範圍。 |
任務失敗時 sink.fail 的錯誤訊息會不會帶帳密 |
不成立(有遮) | 回主系統的 error_message 一律走 safe_error_text(e):先把網址裡的 user:pass@ 換成 ***@,再過 jedi_masking,截 500 字。但注意: 第 138 行 logger.error("task %s 執行失敗: %s", task_uid, e) 是原文進 log,要靠 log 管線的 SecretMaskingFilter 遮;網址形式的 user:pass@ 那個過濾器認不認得,屬 log 管線(不在本棒),建議由掃 log 管線的那棒順便確認。 |
_get_stream/_download 的 dest_path、max_bytes、下載目錄 |
不成立 | dest_path 由 connector 決定,目前唯一的呼叫者 sbom_scan.py:248-251 用 os.path.basename 取檔名、落在本次 workspace 的 input/ 下,不會跳出目錄;max_bytes 由工具限額(upload_max_mb)算出傳入。這兩支只是轉給 sync 的 CloudChannel。 |
_upload_reports 上傳報告前有沒有過遮罩 |
沒有遮,但不報 | 報告內容原樣上傳到本機 blob。掃描報告本來就是證據,裡面的主機帳號是證據的一部分;遮掉反而毀證據。報告只存本機 blob,下載走主控台與資料面守門。 |
running_tasks:同一 task_uid 重複派會怎樣 |
成立但影響低,不報 | register 用新的 Event 直接覆蓋舊的,同 uid 第二次派會並行跑兩次;先跑完的那個 unregister 會把登記清掉,另一個就取消不到了。前提是主系統重複派同一張單(要有 JWT),屬可靠性問題,不是資安漏洞。 |
request_cancel 誰都能取消嗎 |
不成立 | 資料面 /detection/cancel 要主系統 JWT;主控台的取消 scan_routes.py:220 掛 @require_session(SCOPE),只取消 local_runs 的手動執行。沒有比對 started_by,同 scope 的操作者可以取消彼此的手動掃描,這是同權限內的操作,不報。 |
credential_route.py、detection_route.py 掛在哪、守門是什麼 |
不成立 | scan/api/__init__.py 把 /detection/probe、/detection/cancel、/detection/credentials 掛在主 app(8443 資料面),守門是 sync/api/data_plane_auth.py 的 app 層 before_request(mTLS + JWT 綁 op、jti 只能用一次),不在公開清單內。probe 收工具 id/code、帳密、params;cancel 只收 task_uid。 |
secret_form._coerce 型別強轉讓 schema 檢查失效 |
不成立 | boolean 欄只收真正的 bool,字串 "true" 會被退(「只能是是/否」);number 欄明確擋 bool;select 欄比對允許清單。store_call 只接 CredentialStoreCorrupted,回固定訊息 STORE_UNREADABLE,不帶 Fernet 細節。 |
low 強度:只有一個研究員讀全範圍,沒有威脅模型和廣度掃。上面的「重點看什麼」逐條人工查過,補了一層,但掃描結果本來就每次不同。wf_90202827-ce1docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-165640/749d3b4(working tree 乾淨)verification:status: verified、reason_kind: null、candidates: 1、panel_votes: 3、researchers_dispatched: 1、researchers_returned: 1