⚠️ 歷史紀錄:本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線(修正卡 CM-3559~3564、3573~3576、3584,驗收紀錄見母卡 CM-3507)。文中的檔名與行號是掃描當下(commit
bab2aa3)的版本,修正後已變動,請勿拿來定位現行程式。
工具找到 1 個中度(MEDIUM)問題,三位驗證員一致成立:容器形態升級時,主機用 root 去跑「新映像裡的 cat」把升級腳本讀出來執行,而新映像的作業系統層沒有被簽章涵蓋——控制了 Agent 容器(或更新管道)的人可以藉此拿到客戶主機 root。人工逐條查證「重點看什麼」另記 3 個低度觀察(不算正式發現,建議擇期處理)。
Agent 平台層 11 個檔(約 1388 行):Fernet 祕密保管(secret_store.py)、給原廠的診斷包(support_bundle.py)、重啟與本機維運端點(ops.py、local_ops.py)、維運 CLI(local_state_cli.py)、稽核寫入與舊檔匯入(audit_log.py、audit_pipeline.py、audit_import.py)、device_uuid 算法(fingerprint.py、host_identity.py)、升級驗章 CLI(agent_tree_verify.py)。
| # | 嚴重度 | 一句話 | 急不急 |
|---|---|---|---|
| F1 | 中 | 升級時主機以 root 執行新映像內未驗章的 cat,可被換掉而拿到主機 root |
下一個升級相關版本前修 |
| 人工 O1 | 低 | 金鑰檔權限不對只記 warning、照用 | 可排進一般改善 |
| 人工 O2 | 低 | 診斷包 _SECRET_NAME 沒涵蓋 DSN/URL 內嵌密碼的 env(目前清單內只有 DATABASE_URL,已涵蓋) |
新增 env 時注意 |
| 人工 O3 | 低 | device_uuid 兩來源都讀不到時退化成固定值(只 warning,不拒啟) |
靠雲端撞號偵測守門,可接受 |
術語:root=主機最高權限帳號;映像(image)=docker 容器的整包檔案系統;驗章=用原廠公鑰確認檔案沒被改過;uid 1000=容器內 Agent 跑的一般帳號。
docker run --entrypoint cat <新映像> 把新版的 upgrade_runner.sh 讀出來,用 root 的 bash 直接執行。cat 這支程式本身、映像的 PATH 設定都不在簽章範圍內,攻擊者可以塞一支假 cat,讓它印出任意腳本。agent-order/ 目錄,也就是已控制 Agent 容器,或控制主系統的更新推送;②手上有一份原廠真簽章、版號較新的 agent 樹(或主機設了 AGENT_UPDATE_ALLOW_SAME_VERSION=1);③compose 安裝形態,且裝了升級單的 systemd path unit。門檻不低,所以評中度;但它正好是「容器被打穿後能不能逃到主機」的那道牆。deploy/install.sh:2363(run_upgrade 內的 source <(docker run --rm --entrypoint cat "$AGENT_IMAGE_TAG" ...));驗章範圍見 guidant_agent/platform/agent_tree_verify.py:22-29(只驗 manifest 列的檔)。install.sh 在範圍外,只為追蹤資料流而讀。install-lib/upgrade_runner.sh;或 build 時把映像 ID/config digest 一起簽進去,主機先 docker image inspect 比對再用。| 項目 | 結果 | 為什麼 |
|---|---|---|
fingerprint.py+host_identity.py:兩來源都讀不到退到什麼 |
成立(低,O3) | fingerprint.py:58-66:以空字串參與雜湊,退化成固定值 `sha256(" |
| 路徑能不能被環境變數換成任意檔 | 不成立(有界) | config/config.py:183-184 讀 HOST_PRODUCT_UUID_PATH/HOST_MACHINE_ID_PATH。但能改 Agent 環境變數的人本來就是主機 root/能改 .env 的人,而且註冊還要一次性 token,換路徑不會增加新能力。 |
| 容器內 machine-id 是掛主機的還是容器自己的 | 不成立 | deploy/docker-compose.yml:203-204 唯讀掛宿主的 /etc/machine-id 與 host-id/(product_uuid 由 install.sh 複製成 0440),重建容器不會漂移。 |
support_bundle.redacted_settings 的 _SECRET_NAME |
部分成立(低,O2) | support_bundle.py:35 抓 `SECRET |
_journal_tail 有沒有過 mask_text |
不成立 | support_bundle.py:171:log 不論來自 journald 或記憶體,每一行都過 mask_text。 |
recent_diagnostics 含內網拓撲要不要出去 |
設計取捨,非漏洞 | 診斷包本來就是給原廠支援看網路問題的,內容過 _masked_json;由客戶自己下載、自己決定給不給。 |
排除清單三項,build 有沒有讀到 allowed_targets/secrets/ |
不成立 | build(support_bundle.py:149-175)只寫 manifest/log/env 設定/儀表板/最近診斷五段,不讀資料目錄的任何檔。 |
secret_store._read_key(create=True) 建檔權限 |
不成立 | secret_store.py:98:`os.open(..., O_CREAT |
:107 權限不對只 warning |
成立(低,O1) | secret_store.py:106-107:權限不是 600 時只記 log,照讀照用。建議改成拒用或自動收緊。 |
| 金鑰與密文同目錄 | 設計取捨 | 防的是:「只拿到密文檔」、備份外流到沒有金鑰的地方、其他非 root 帳號(0600/0700)。不防的是:整個資料目錄被複製,或主機 root 被拿下。這點與 .claude/rules/security/secrets.md 的決定一致(不從 mTLS 私鑰派生)。 |
ops.request_restart 的 note 長度與遮罩 |
不成立 | ops.py:157 截到 200 字;ops.py:164 的 log 不帶 note;log 全走 audit_pipeline 的 SecretMaskingFilter。 |
local_ops loopback 端點各自能做什麼 |
不成立 | 只有兩支:revoke-admin-sessions(登出本機管理員)與 cancel-running(取消工作),都是「收緊」動作,沒有改設定、讀祕密或觸發更新的端點。而且除了 loopback,還要帶 root 才讀得到的 0600 權杖(local_ops.py:58-61),所以容器內同 pod 的行程不夠打到。 |
local_state_cli 子命令有沒有印祕密 |
不成立 | 六個子命令(local_state_cli.py:134-141)都沒印密碼;set-admin-password 只從 stdin 讀。守門=能執行這支 CLI 且能寫 DB=root(資料目錄權限),符合設計。 |
audit() kwargs 有沒有帶密碼/token |
不成立 | 範圍內的呼叫只帶 sub/first_time/method/reason/各表刪除筆數。 |
audit_import 會不會覆蓋既有紀錄 |
不成立 | audit_import.py:154-175:只 append 新列,先查 AGENT_LEGACY_AUDIT_IMPORTED 防重複匯入,舊檔改名不刪。 |
agent_tree_verify 路徑參數有無限制、版號從哪來 |
部分成立(併入 F1) | 能驗任意目錄是無害的(只讀、只回結果);版號來自已驗章的 manifest(agent_tree_verify.py:33),可信。問題在「驗完之後主機跑了什麼」,即 F1。 |
deploy/install.sh:2363 核對過程式碼確實如此。wf_68fd2842-89a;歸檔:docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-174556/63334f4(工作樹乾淨),effort low,範圍 11 檔,耗時約 49 分鐘verification.status: verified;reason_kind: null;candidates: 1;panel_votes: 3;researchers_dispatched: 1;researchers_returned: 1