檢查日期 2026-09-25|對應卡片 CM-2163(母卡 CM-2152)|檢查範圍 7 個檔案 1,548 行|工具 run ID
wf_408b5225-5ee
收集器取主機資料的 docker 指令都是寫死的,資料庫讀取也只看兩張紀錄表,這兩件沒問題。問題出在「收進包之前遮得乾不乾淨」和「指令版在主機上用的暫存資料夾」。
工具正式清單 2 條,三人面板都是 3:0 通過,兩條都是中:
password='明文' 的寫法進日誌,診斷包原樣帶走。 遮罩只認 JSON 的 "password":"值" 和 password: 值 兩種寫法,不認這一種。我實測確認兩支遮罩都漏。sudo guidant diag 在「容器使用者寫得動的資料夾」裡,用一個猜得到的名字建暫存資料夾,而且不檢查那是不是捷徑(符號連結)。 已經在容器裡拿到一般權限的人可以預先放捷徑,讓主機的最高權限帳號把檔案寫到別的地方去。我在自己的暫存資料夾裡照同樣手法重現成功。我照卡片逐支開檔,另外找到 3 條(未經三人面板投票):
?access_token=…)時會原樣進包。DEV 查到 1 筆,是測試資料。不重報的:容器紀錄完全沒過遮罩(總表第 173 項已登記,即 U2-2)。這一棒順帶確認,指令版的「預蒐模式」同樣沒過遮罩,兩條路是同一個洞,修的時候要一起修(見下方疑點 ①)。
「系統診斷包」是客戶系統出問題時,把整台機器的現場打成一個壓縮檔寄給原廠分析用的。這個包不加密,會經過客戶的電腦、郵件或隨身碟、原廠的電腦,最後被貼進 AI 對話。所以包裡的密碼有沒有遮乾淨,就是這個功能唯一的防線。
U10 查的是「誰能按下產包」和「組裝流程」,這一棒 U11 查的是真正去拿資料的那七支程式:它們去哪裡拿、拿的時候怎麼組指令、拿到後有沒有遮。
| 檔案 | 行數 | 做什麼 |
|---|---|---|
infra/support/collector/host_collector.py |
201 | 開發機/原生部署用:直接跑 docker ps、docker logs、df,讀設定檔 |
infra/support/collector/staged_collector.py |
178 | 出貨主機用:主機端的腳本先把資料存進暫存資料夾,這支再讀進來 |
infra/support/collector/container_collector.py |
105 | 畫面版用:在容器裡面,拿不到 docker 就誠實寫「取不到」,設定改讀當下的環境變數 |
infra/support/diag_db_reader.py |
242 | 讀資料庫的存取紀錄、錯誤紀錄、改版水位、各表筆數 |
infra/support/diag_log_reader.py |
176 | 依時間範圍切應用日誌檔 |
infra/support/diag_event_fetcher.py |
303 | 從錯誤收集站撈錯誤清單 |
infra/support/diag_packer.py |
343 | 全部打成 tar.gz,超過大小就從最舊的開始砍 |
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度+為什麼 | 來源 |
|---|---|---|---|---|---|---|
| U11-1 | 遮罩不認得 password='值' 這種寫法,而寄信套件寄信失敗時就是這樣把整組寄信設定寫進日誌的 |
客戶的寄信伺服器帳號密碼跟著診斷包寄到原廠、貼進 AI 對話;包裡的說明檔還寫著「密碼都已遮罩」 | 客戶有設寄信帳密,時間範圍內寄信失敗過一次(封閉網路很常見),然後有人產診斷包。不需要攻擊 | 遮罩規則 infra/support/diag_masking.py:164-169(補認 名稱=值、名稱='值');資料庫紀錄改走完整日誌遮罩 infra/support/diag_db_reader.py:237;源頭在寄信套件 jedi_notification/infra/smtp_mail/smtp_mail_adapter.py:52、:57 |
🟠 中:正常維運就會發生,但外洩對象是拿到包的人,不是任意網路使用者 | 工具(面板 3:0) |
| U11-2 | 指令版在容器寫得動的資料夾裡,用猜得到的名字建暫存資料夾,不檢查是不是捷徑 | 已經在容器裡拿到一般權限的人,可以讓主機最高權限帳號改掉主機上任意檔案的權限,或把內容寫進去 | 先在容器裡拿到一般使用者的執行權(要另一個漏洞),而且主機管理員之後跑了 sudo guidant diag |
scripts/installer/guidant:700-706(改用 mktemp -d 建在只有 root 能寫的地方;或先檢查路徑不存在、不是捷徑) |
🟠 中:後果是從容器跨到主機,但要先有另一個漏洞,還要等管理員跑診斷 | 工具(面板 3:0) |
| U11-3 | 畫面版的設定快照遇到跨多行的值,只遮第一行 | 資料庫密鑰 JSON 的第二行、私鑰檔的內容(如果環境變數直接放內容而不是路徑)原樣進包 | 平台管理員從畫面產包,而且當下的環境變數裡有跨多行的機密值 | infra/support/collector/container_collector.py:94(不要把環境變數串成 名稱=值 的文字再遮,改成逐個判斷名稱) |
🟠 中:正常產包就會發生,前提是有多行機密值;出貨設定檔裡的 DB_SECRET 是單行,所以今天的落地版沒中 |
runner 實測(未經三人面板) |
| U11-4 | 預蒐模式讀檔時,完全照索引檔寫的路徑去讀,沒限制在暫存資料夾裡 | 能改索引檔的人,可以讓容器內用最高權限跑的打包程式把容器裡任何檔案讀進包 | 要能寫暫存資料夾,而這其實就是 U11-2 的前提 | infra/support/collector/staged_collector.py:90(取實際路徑後,確認還在 staging_dir 底下);:140(服務名稱也要驗,不然包內檔名會出現 ../) |
🟢 低:前提跟 U11-2 相同,後果比它小 | runner 實測(未經三人面板;工具在 U11-2 裡提到這一半) |
| U11-5 | 存取紀錄的「網址」欄完全不遮 | 網址裡帶的權杖參數原樣進包 | 有人用網址參數傳權杖(本產品的正常端點不這樣做),加上有人產包 | domain/support/entity/diag_bundle.py:30(MASKED_API_LOG_COLUMNS 補上 url);遮罩也要認得網址參數 |
🟢 低:產品本身不用網址傳權杖,DEV 只有 1 筆測試資料 | runner 開檔+DEV 查證(未經三人面板) |
現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。
- U11-1(遮罩不認
password='值')=總表第 207 項,✅ 已修(寄信套件 CM-2111;遮罩規則 CM-2212,1.21.0 出貨)。- U11-2(診斷指令暫存資料夾)=總表第 208 項,✅ 已修(CM-2214,commit
d39181f3b/046553756,1.21.0 出貨)。- U11-3(設定快照多行只遮第一行)=總表第 209 項,✅ 已修(CM-2212,1.21.0 出貨)。
- U11-4(預蒐模式路徑未限制)=總表第 210 項,✅ 已修(CM-2214,1.21.0 出貨)。
- U11-5(網址欄不遮)=總表第 211 項,✅ 已修(CM-2212,1.21.0 出貨)。
工具派了一位研究員讀整個範圍,另一位專找寫死在程式裡的密碼金鑰。第一位研究員跑了約 90 分鐘被中斷,工具自動重派一次,這次跑完。兩個候選都送進三人面板,6 票全投完,兩條都是 3:0 通過。
password='值' 寫法進日誌,遮罩漏掉(中)infra/support/diag_masking.py:164-169(日誌那一道),要補認 名稱=值 和 名稱='值'infra/support/diag_db_reader.py:237jedi_notification/infra/smtp_mail/smtp_mail_adapter.py:52、:57Error sending email smtp info: … username='u' password='明文' …。這個寫法是 Python 印物件的預設格式。診斷包的遮罩只認兩種寫法:JSON 的 "password":"值",和日誌裡的 password: 值,不認 password='值'。所以這行會原樣進 logs/app.log.slice,也會進 db/system_logs.csv。smtp_mail_adapter.py:52、:57 真的是 logger.error(f"... smtp info: {self.config}")。設定物件的 password 是一般字串(smtp_email_config_dto.py:13),不是會自動隱藏的密碼型別。app/notification/service/notification_service.py:67 把資料庫裡存的寄信密碼放進這個欄位。mask_json_like 和 mask_log_lines 都原樣留下假密碼。system_logs 與本機 log/app.log* 目前都沒有 smtp info 這一行,因為 DEV 沒有寄信失敗過。所以這只證明「一失敗就會外洩」,不代表 DEV 已經外洩。名稱=值 的缺口。這一條是那個缺口的第一個確認的真實來源。建議首腦判斷要併進 U10-1(與第 162 項一起),還是單獨一條。名稱=值 和 名稱='值'(鍵名清單沿用同一份);資料庫紀錄改走 mask_log_lines。寄信套件那半(不要印整個設定,或把密碼改成隱藏型別)照外部套件異動規範先提醒、決策者點頭才動。scripts/installer/guidant:700-706(範圍外,但資料是流進本範圍的 staged_collector.py)sudo guidant diag 會以主機最高權限(root)建一個暫存資料夾,路徑是 /srv/guidant-ai/home/.diag-stage-<程序編號>,然後往裡面寫檔、改權限。home 這個資料夾同時掛進容器、而且屬於容器裡的一般使用者(uid 1000),所以容器裡的程式也寫得動它。guidant:700 路徑的組法、:705-706 的 mkdir -p 和 chmod 700、之後的 > 寫檔與 cp,一路都沒檢查是不是捷徑。docker/production/docker-compose.yml:152 把 home 掛進容器;install.sh:1319 和 entrypoint 把它設成 uid 1000 所有。mkdir -p、chmod、> 寫檔 → 權限改到捷徑指的資料夾,檔案內容也寫進捷徑指的檔案。mktemp -d 建在只有 root 能寫的地方,再用唯讀方式掛給容器;至少先檢查路徑不存在、也不是捷徑。容器內的打包程式改用 uid 1000 跑(docker exec -u 1000),除非真的需要 root。逐支開檔核對的結果:
| 收集物 | 從哪支來 | 打包前的遮罩 | 結論 |
|---|---|---|---|
容器狀態(docker ps)、資源用量、磁碟 |
三支收集器 | 沒有 | 不需要:內容是容器名稱、狀態、用量,不含機密 |
| 設定檔(指令版) | host_collector.py:197、staged_collector.py:174 |
mask_env_text |
有遮,但規則窄(見 U10-1) |
| 設定快照(畫面版) | container_collector.py:94-95 |
mask_env_text |
有遮,但跨多行的值只遮第一行 → U11-3 |
| 容器紀錄(開發機直接收) | host_collector.py:136-156 |
沒有 | 總表第 173 項(U2-2),不重報 |
| 容器紀錄(出貨主機預蒐) | staged_collector.py:128-153 |
沒有 | 同一個洞的另一條路。實測:假權杖三種寫法全部原樣留下、遮罩清單空白。修第 173 項時這支要一起改 |
| 應用日誌 | diag_log_reader.py:152 |
mask_log_lines |
有遮,但不認 名稱=值、名稱='值' → U11-1 |
| 存取紀錄、錯誤紀錄(資料庫) | diag_db_reader.py:237 |
mask_json_like(最弱) |
只遮四個欄位;url 欄沒遮 → U11-5 |
| 錯誤收集站事件 | diag_event_fetcher.py:275-287 |
mask_structure |
有遮;欄位名稱像密碼的整個換掉 |
| 各表筆數、改版水位、分區 | diag_db_reader.py:175-222 |
不需要 | 只有數字與版號 |
KEY=值/Bearer/連線字串?——❌ 認得的不夠 → 成立(併入 U10-1,新增一個真實來源 U11-1)我寫了一支小程式,拿假密碼逐一丟進三支遮罩。跟 U10 的實測結果一致,另外補了三種:
| 寫法 | 設定檔遮罩 | 日誌遮罩 | 資料庫紀錄遮罩 |
|---|---|---|---|
password='值'(寄信套件的真實寫法) |
— | ❌ | ❌ |
Authorization: Bearer … |
— | ✅ | ❌ |
行中間的 DB_PASSWORD=值 |
— | ❌ | ❌ |
postgresql://帳號:密碼@主機、redis://:密碼@主機 |
❌ | ❌ | ❌ |
SMTP_PASS=值、JWT=值(名稱不含 password/secret/token/key) |
❌ | — | — |
{'password': '值'}(單引號字典) |
— | ❌ | ❌ |
| 跨多行的值(第二行起) | ❌ | — | — |
設定檔的部分:出貨設定檔裡真的放機密的欄位(資料庫、Redis、S3、JWT、加密金鑰、AI 金鑰)名稱全部含 password/secret/key,都遮得到,今天出貨的設定檔本身不會外洩(U10 已實測,本棒再以 install.sh 寫出的欄位名單核對一次)。問題在日誌與資料庫紀錄裡混進來的機密。
host_collector.py):docker ps、docker stats、docker logs、df、du 全部用清單形式呼叫(不經過 shell),服務名稱來自檔案裡寫死的清單 KNOWN_SERVICES,時間參數是程式算出來的時間字串。沒有任何外部輸入進得了指令。不成立。host_collector.py:68 是 資料目錄/guidant.env,資料目錄來自指令參數 --data-dir,只有能在主機下指令的人才給得了。不成立。diag_log_reader.py:85-100 只列出日誌資料夾裡、檔名以 app.log. 開頭的檔,路徑來自日誌設定。不成立。staged_collector.py:90):staging_dir / 索引檔裡寫的檔名,沒檢查是否跑出暫存資料夾。實測:索引檔寫 ../outside/secret.txt,就把暫存資料夾外面的檔讀進包;服務名稱寫 ../../evil,包內檔名就變成 logs/docker-../../evil.log。→ 成立(U11-4),前提是能寫暫存資料夾,也就是 U11-2 的前提。diag_event_fetcher.py:71-93 來自環境變數,不是使用者輸入。下一頁的網址取自對方回傳的 Link 標頭,最多 10 頁、每次 10 秒逾時,而且跳去別的主機時 Python 會把權杖標頭一起帶過去。但要做到這一步得先控制錯誤收集站本身,所以不列。cm_app,install.sh:1386),每次查詢前設 SET LOCAL app.is_super_admin = 't'(diag_db_reader.py:63)。api_logs、system_logs。DEV 唯讀查證(2026-09-25 18:10 +08)這兩張表都沒有客戶欄、也沒開資料庫隔離(relrowsecurity=false),本來就是整台機器共用的紀錄。另外兩樣是改版水位,和各表筆數(估計值,只有數字)。:45),查詢的表名與欄名是檔案裡寫死的常數,值都走參數綁定,沒有注入的空間。domain/support/entity/diag_bundle.py:21),用未壓縮大小的 3 倍當預算先砍,寫完再以實際大小核對,還超過就寫進包裡的說明檔(diag_packer.py:163-181)。:203),不是還原到別人機器時才要擔心的那種路徑。--no-limit 關掉上限,但那要主機 root,不算漏洞。| 樣式 | 結論 |
|---|---|
| 只驗身分、不驗資料歸屬 | 不適用:收集器不做守門,守門在 U10 的入口,已確認只有平台管理員/主機 root |
| 列表有查、單筆沒查 | 不適用 |
| 守門寫在網址層,第二支路由沒掛 | 不適用:收集器沒有路由 |
守門條件用 or 串起來 |
不適用 |
| 「查不到」和「沒權限」混在一起 | 不適用 |
| 背景或系統身分執行時,假設呼叫者一定是自己人 | 成立一半:預蒐模式假設「暫存資料夾裡的東西一定是主機端腳本放的」,所以照索引檔讀檔(U11-4)。但那個資料夾容器使用者也寫得動(U11-2) |
| 防重放、計次只在單一進程內有效 | 不適用 |
| 註解說這裡刻意不檢查,上一層卻沒檢查 | 成立一種變形:diag_masking.py:26-28 寫「四條路徑共用同一份判準,不分岔」,但資料庫紀錄只走最弱那道、容器紀錄一道都沒走(U10-1、第 173 項、U11-1) |
嚴重度:🟠 中
該補檢查的位置:infra/support/collector/container_collector.py:94
白話說明:畫面版在容器裡拿不到設定檔,改讀「當下生效的環境變數」,做法是把每個變數串成 名稱=值 一行一個,再用設定檔那支遮罩去遮。遮罩是一行一行比對的。如果某個值本身跨好幾行,只有第一行開頭是 名稱=,會被遮到;後面幾行看起來只是普通文字,原樣留下。
實測:造一組假環境變數丟進去:
DB_SECRET JSON → 第二行的假密碼留下AGENT_JWT_PRIVATE_KEY → 中間那行留下CELERY_BROKER_URL=redis://:假密碼@… → 名稱不像密碼,整行留下REDIS_SECRET(單行)、SENTRY_DSN、AI 金鑰 → 遮掉更麻煩的是,遮罩清單會列出 DB_SECRET、AGENT_JWT_PRIVATE_KEY「已遮罩」,讀包的人會以為它們遮乾淨了。
影響:今天的落地版不會中。install.sh 寫出的 DB_SECRET、REDIS_SECRET 是單行;AGENT_*_KEY 放的是檔案路徑,不是內容。但只要部署端把機密以多行值或連線網址放進環境變數,就會原樣進包。 我沒有在 DEV 或出貨設定裡找到這種現況。
建議修法:不要先串成文字再遮。直接逐個看環境變數的名稱,像密碼就整個值換成 ***;值裡有 ://帳號:密碼@ 的也遮。
infra/support/collector/staged_collector.py:90(讀檔路徑)、:140(包內檔名用的服務名稱)staged.json 說明每個檔是什麼。容器裡的打包程式照索引檔寫的檔名去讀,但沒有檢查檔名裡有沒有 ../ 這種「往上一層」的寫法。file 寫成 ../outside/secret.txt → 把暫存資料夾外面的檔讀進包,而且因為被歸類成「容器狀態」,完全沒過遮罩;service 寫成 ../../evil → 包內檔名出現 logs/docker-../../evil.log。docker exec 沒指定使用者、映像檔也沒設 USER),所以讀得到容器裡任何檔案。staging_dir 底下,不在就回「取不到」;服務名稱只允許 KNOWN_SERVICES 裡的名字。domain/support/entity/diag_bundle.py:30(MASKED_API_LOG_COLUMNS 只有 params、request、response、message)url 存完整網址(含 ? 後面的參數,common/middleware/app_mw.py:188)。這欄不在遮罩名單裡,就算在,現在的遮罩也不認網址參數的寫法(?token=值)。api_logs 約 2 萬筆裡有 895 筆網址帶參數;名稱像權杖/密鑰的只有 1 筆 ?access_token=qs-…,看起來是測試打的。Google 雲端硬碟回呼帶的 code= 參數目前 0 筆。url 加進遮罩欄位名單,遮罩補認網址參數。和 U10-1 一起改。第一層:工具正式報告(經三人面板驗證)
verified(stamp CLAUDE-SECURITY-REVISION-1bafa3bfe2e7-dirty.json)failed 0、續跑 0、沒有遺失的候選。research:repository:all 啟動兩次),failed 計數仍是 0。第二層:runner 自行開檔核對+實測(未經三人面板投票)
infra/support/diag_masking.py、scripts/installer/guidant 的 diag 段、install.sh 寫設定檔那段、出貨版 compose、Dockerfile、entrypoint、app/support/diag_cli.py、打包核心、寄信套件的 adapter 與設定物件。../ 路徑與容器紀錄遮罩SELECT):兩張紀錄表的隔離設定、system_logs 的密碼形狀(10 筆都是堆疊裡印出的程式碼、值是變數名)、smtp info 筆數(0)、api_logs 網址參數。| 項目 | 值 |
|---|---|
| 掃描目標 | BE repo compliance-manager-be,branch feature/review |
| 版本 | 1bafa3bfe(工作區有平行線未 commit 改動,範圍 7 支檔案本身無改動) |
| 工具 | Claude Code 官方 claude-security plugin 0.11.0 |
| 參數 | mode scan/effort low/focus attack-surface/scope 7 檔 |
| run ID | wf_408b5225-5ee |
| 報告目錄 | CLAUDE-SECURITY-20260925-082706/(不入版控) |
| 耗時 | 約 181 分鐘(10,858 秒,含第一位研究員被中斷後重派) |
| 研究員 | 2 派出/2 交回(全範圍 1+密碼金鑰專掃 1;全範圍那位被中斷後重派 1 次),failed 0 |
| 候選/面板票 | 2/6(兩條都是 3:0 通過) |
| 驗證章 | verified |
| 工具發現 | 2 條(中 2) |
| runner 自行發現 | 3 條(中 1、低 2),皆未經面板 |