掃描日期:2026-09-08 掃描版本:compliance-manager-be
feature/FR-075@bc1c3381(工作目錄有未 commit 變更) 工具:Claude Codeclaude-securityplugin(claude-security:scanworkflow) 範圍:七條路徑、18 個受版控檔案——app/notification/service/、app/notify_config/service/、api/notify_config/、infra/notification/、infra/system_config/system_config_root_reader.py、di_containers/notification/、di_containers/notify_config/effort:low,focus:attack-surface模型:主 session Opus 5 (1M context),研究員繼承 狀態:✅ 驗證面板完整跑完(分兩輪),stamp 為verification.status: verified——中途撞額度上限,但工具自己把不完整的那條交棒重驗,沒有拿部分票充數 對應卡片:CM-1604(母卡 CM-1602)
範圍內找到 2 條問題,核心那一條正是這一棒開來找的東西:測試寄信端點會把平台存的 SMTP 密碼,送到呼叫端自己指定的郵件伺服器——業務租戶的管理員填一個自己的主機位址,就能把平台營運方的郵件憑證收下來。另外 4 條是掃描順手做的密鑰檢查撈到的範圍外發現(版控裡的硬編憑證),其中一半已在掃描當天處理完,另一半的密碼至今仍有效。
先看這張表就好,每條的完整說明、程式碼行號與建議修法在後面各自的章節(點 ID 跳過去)。
| # | 嚴重度 | 這是什麼問題(白話) | 出事會怎樣 | 要先有什麼才打得到 | 位置 | 修正卡 |
|---|---|---|---|---|---|---|
| F1 | 🟠 HIGH | 郵件設定頁的「寄測試信」按鈕:你不改密碼欄,系統就拿它存的那組真密碼去試寄;但伺服器位址是你填的 | 子租戶的管理員拿走平台營運方的郵件帳密。之後他能用貴公司名義寄信、且會通過 SPF 驗證,收件人看不出是偽造的。設定裡填 tls: false 的話密碼還是明文送過去 |
一個持有 smtp-config.update 能力點的登入帳號(這個能力點是刻意下放給業務租戶管理員的),加上一台他控制得了的 SMTP 監聽 |
test_mail_service.py:84(回填密碼)test_mail_service.py:116(拿去連他指定的主機) |
CM-1605 |
| F5 | ⚪ LOW | 「測試 Discord 頻道」按鈕:填什麼網址就打什麼,沒有任何位址限制。還有一條更低權限的變體——把內網網址存起來,之後每次系統發通知都會去打它 | 拿我們的伺服器當跳板探測客戶內網:哪台機器活著、哪個埠開著、雲端 metadata 端點(169.254.169.254)打不打得到。回應內容不會回給呼叫端,所以偷不到資料,只能探測與觸發副作用——面板因此把研究員報的 MEDIUM 降成 LOW |
測試路徑要 notify_config.update + 帳號層 super admin;存起來的變體只要 notify_config.update |
notify_config_test_service.py:86(測試)notification_service.py:90(存起來後每次發通知都打) |
CM-1605(併)※ |
| F2 | 🟠 HIGH ⚠️ 範圍外 |
一個叫 .env.test 的檔案被刻意排除在忽略清單外、推上了 origin,裡面是四把真的對外 API 金鑰(不是範本值) |
直接的財務損失——任何拿到 repo 的人都能用貴公司帳號呼叫這些外部 AI 服務;還能讀走服務端存的執行軌跡,裡面例行含有應用提示詞與客戶資料 | 只要讀得到 repo(包含 clone、鏡像、CI 快取),不需任何網路位置 | .env.test 四行 |
✅ 已處理 |
| F3 | 🟡 MEDIUM ⚠️ 範圍外 |
同一個檔案裡還有 JWT 簽章密鑰與 DB/Redis/MinIO 的密碼 | 拿到 JWT 密鑰就能自己偽造一張管理員身分的登入憑證(不需要任何網路位置);DB 密碼則能直接連進資料庫繞過所有應用層授權 | 讀得到 repo;DB/Redis/MinIO 部分另需內網或 VPN 連得到 | .env.test 四行 |
✅ 已處理 |
| F4 | 🟡 MEDIUM ⚠️ 範圍外 |
一支產生資料庫結構文件的腳本,把 POC 環境的完整連線資訊(含密碼)寫死在程式碼裡 | 直接連進客戶會看到的 POC 資料庫——專案自己的 CLAUDE.md 就寫明 POC 等同 production。讀得到、視 RLS 而定可能改得到客戶的展示資料 | 讀得到 repo + 連得到那台機器(內網或 VPN) | docs/system-design/scripts/generate_db_schema_docx.py:34 |
CM-1608 |
| F8 | 🟡 MEDIUM ⚠️ 範圍外 |
blsadmin 這個系統管理員帳號的密碼寫死在三支腳本裡。其中兩支寫成「環境變數沒設就用這個」——而那個「這個」就是真密碼,變數沒設時靜默使用 |
拿到 repo 就等於拿到一組可用的管理員帳密,登入任何還在用這組密碼的環境即取得管理員 session——而管理員正好有權改 SMTP 設定,可以直接接上 F1 那條鏈 | 讀得到 repo + 連得到某個該帳號仍用這組密碼的環境 | scripts/e2e_test_module_frame_with_docx.py:38(無逃生門)scripts/migrate_2026-08-09_fr062_existing_tenant_licenses.py:80scripts/seed_2026-08-01_fr059_detection_profiles.py:65 |
CM-1608 |
※ F5 併入 CM-1605:同屬通知設定的「使用者可控目標位址」問題族,改動相鄰。
另有一項首腦查證的延伸發現(工具沒報):F2 那批已撤銷金鑰的字串還散在版控裡 15 個檔,見下方專節。對應卡 CM-1607。
「這六條存在嗎」與「只有這六條嗎」要分開回答。
存在:可信。 六條全部經過完整的三人對抗式面板(reachability/impact/defenses 各一票),stamp 記 verification.status: verified,unreviewed_candidate_sites: 0——沒有任何一條是靠部分票或無票通過的。其中五條 3/3 全票;F1 是 2/3,但那張反對票首腦判定不成立(理由見 F1 章節),且首腦已親自開檔核對 F1 的行號與守門形狀屬實。
「只有這六條」:不可信,而且有兩層不可信。
第一層是通例:low effort 單次快篩,沒有 inventory、沒有威脅模型、沒有廣度掃蕩,工具自己把 completenessCheckOutcome 標成 not-applicable。
第二層是這一棒特有的:四條範圍外發現不代表那些目錄被掃過。 F2/F3/F4/F8 是研究員為了追脈絡讀到範圍外時,順帶由密鑰專項檢查撈出來的。.env.test、docs/、scripts/ 三棵樹從來沒有被當成掃描目標。 這四條的存在不能讀成「那三處只有這些問題」——那三處等於沒掃過。
同樣地,掃描過程沒有執行任何被掃的程式碼——沒跑測試、沒發攻擊、沒拿任何憑證去連任何服務。
| 項目 | 數字 |
|---|---|
| 研究員派出 / 回報 | 2 / 2 |
| 候選發現 | 9 條原始 → 去重後 7 條 |
| 面板票數 | 23 |
| 面板審過的發現 | 6(panel_reviewed_findings: 6,panel_quorum_findings: 6) |
| 未投票候選 | 0(unreviewed_candidate_sites: 0) |
| 驗證輪次 | 2(第一輪撞額度中斷,第二輪續完) |
| 總耗時 | 約 3 小時 58 分(14,298 秒) |
這是本棒最值得記下來的一件事,也是與 FR-077 R1 最關鍵的差別。
第一輪面板驗到候選 C6 時撞上 session 額度上限。那一條的三個角度驗證者回來了兩個,第三個初次派發加四次重試全部失敗。
工具沒有拿兩票充數。 它把這件事記進 adversarialCasualties:
F6: panel incomplete (2/3 voters returned), handed to the next verification run
然後把該候選交棒給下一輪,而不是把它算成「已驗證」或「未驗證」。額度重置後跑第二輪,用完整的三人面板重新驗那一條——就是報告裡的 F8(blsadmin 密碼)。
lostCandidates 是空的,最終狀態零待決候選。這份報告裡沒有任何一條是靠不完整的面板通過的。
7 個候選 × 3 票 = 21,實際 23 票。多出的 2 票,正是第一輪那個候選作廢掉的那兩張部分票。
換句話說:續跑不是「接續舊票再補一張」,是整條重投。 這是正確的做法——三人面板的價值在於三個角度互相制衡,補一張票進去等於讓那一票在已知另外兩票結果的情況下投,性質完全不同。
7 個去重候選中有 1 個被面板全票否決(0 真 / 3 假),不會出現在報告裡。F6/F7 的跳號,就是那個否決加上續驗候選重新編號的結果。
兩棒都「撞到額度」,但發生的事完全不同:
| FR-077 R1 | 本棒 N2 | |
|---|---|---|
| 面板 | 全滅——105 個 verifier 全失敗,21 票 0 張投出 | 完整跑完——23 票全部投出 |
| 工具正式 findings | 空陣列 | 6 條 |
| stamp | verification.status: unverified |
verified |
| 候選怎麼處理 | 全部標 continued: true,工具無法收束 |
1 條交棒續驗完成,其餘正常結束 |
| 結論怎麼來 | 人工從 workflow 回傳撈回來,runner 逐條開檔核對補上驗證 | 工具自己完成,首腦只做抽查複核 |
R1 是工具失敗、人接手;N2 是工具中斷、工具自己接住。 描述本棒時不可套用 R1 的語言(「面板沒跑成」「結論未經背書」),那會嚴重低估這份結果的可信度。
嚴重度:HIGH · 面板:2/3(一票反對,首腦判定該反對理由不成立)· confidence:medium · CWE-522 位置:app/notification/service/test_mail_service.py:84(回填既存密碼)→ :116(拿著它連呼叫端指定的 host),位於 TestMailService.send_test_mail 首腦已開檔核對:✅ 屬實
郵件設定頁上有個「寄測試信」按鈕。使用者的操作直覺是:「我不想重打密碼,就讓它用存好的那組試一下。」系統於是照辦——從 DB 撈出既存的真密碼,貼進這次送來的設定裡。
問題在於:這次送來的設定裡,郵件伺服器位址也是使用者填的。
於是完整的攻擊路徑只有一步:填一個自己控制的主機位址、密碼欄不改、按下測試。系統就會拿著平台真正的郵件帳號密碼,去登入攻擊者的伺服器——攻擊者的監聽程式只要聲稱支援 AUTH PLAIN,就把帳密收下了。設定裡填 tls: false 的話,連加密都省了,密碼是明文過去的。
N1 F1 是「密碼掉進 log,攻擊者要有辦法讀到 log」。這一條是密碼被主動送到攻擊者指定的機器上——不需要等它掉進哪裡,直接收貨。
而洩漏的東西的歸屬更關鍵:SMTP 設定存在 ROOT 租戶、整個平台共用一份,但「改 SMTP 設定」這個能力點是刻意下放給業務租戶管理員的。 所以場景是:子租戶的管理員拿走平台營運方的郵件憑證。 拿到之後他能用該組織的名義寄信,而且因為用的是真正的郵件伺服器,SPF 驗證會通過——收件人從技術指標上分辨不出是偽造的。
POST /api/1.0/mail/test
body 內的 smtp_config(host / port / user / tls) ← 完全由呼叫端提供,未經驗證
changePwd = false(預設)
→ test_mail_service.py:84 把 ROOT 租戶存的真密碼寫進這份設定
→ test_mail_service.py:116 用這份設定連線並 server.login()
route 的守門只有 @auth_required + @capability_required,兩者都只回答「你是誰」,不回答「你送來的主機能不能連」。而 schema SendMailTestRequest 掛在 mail_route.py:28 時帶的是 apply=False——等於只是文件,完全沒有做驗證,而且它宣告的欄位只有 to,smtp_config 整包從來沒被任何 schema 檢查過。
面板是 2/3 通過,唯一的反對票來自 reachability 角度。必須把這件事寫清楚,因為那張票的理由恰好把因果關係搞反了。
反對者不否認漏洞存在,也不否認缺少驗證。他的理由是:mail_route.py:31 有 @capability_required 守門,這縮小了可觸及的範圍。
首腦判定不成立,理由是:smtp-config.update 這個能力點正是刻意下放給業務租戶管理員的——infra/system_config/system_config_root_reader.py 內 CM-1331 的說明白紙黑字寫著這個設計意圖。
所以持有這個能力點的人,本來就包含攻擊者情境裡的那個人。守門的存在不是防護,守門存在恰恰是攻擊成立的前提——正因為子租戶管理員被授權改 SMTP 設定,「子租戶管理員拿走平台營運方憑證」這個場景才有意義。把它當成緩解因素,等於把威脅模型讀反了。
這也是為什麼 confidence 標 medium 而嚴重度仍是 HIGH:medium 反映的是「面板未達一致」這個程序事實,不反映首腦對實質的判斷。首腦已開檔核對
test_mail_service.py:60-117與jedi-notification/.../mail_route.py:20-41,守門形狀與apply=False皆與報告逐字相符。
這是本 codebase 第二次踩到「測試連線時回填既存密碼、但目標位址可控」。 LDAP 那條已在 CM-1564 修掉,手法是位址改變時強制要求重新輸入密碼。SMTP 這邊沒有跟著修。
change_pwd 為 false 時,只有在「送來的連線目標與存的那組完全一致」(host/port/username/tls 四項全等)才回填存的密碼;否則要求呼叫端自己填密碼。SendMailTestRequest 的 apply=False 改成實際套用,並補上 smtp_config 的欄位定義——現在它連基本的型別檢查都沒有。嚴重度:LOW(面板從研究員報的 MEDIUM 下修,投票為 LOW/LOW/MEDIUM)· 面板:3/3 全票確認存在 · confidence:medium · CWE-918 位置:app/notify_config/service/notify_config_test_service.py:86,位於 NotifyConfigTestService._test_discord
「測試 Discord 頻道」按鈕會拿使用者填的 webhook 網址去發一則訊息。程式逐字照用那個網址——沒有檢查它是不是真的 Discord、沒有限制通訊協定、沒有限制主機、沒有限制轉址。
所以使用者可以填 http://10.0.0.15:8500/... 這種內網位址,我們的伺服器就替他去打。伺服器位在客戶的網路裡面,能連到很多外面連不到的東西——內部管理介面、雲端 metadata 端點(169.254.169.254)、localhost 上的管理埠。
回應內容不會回給呼叫端。 呼叫端只看得到「成功」或 NOTIFY_CHANNEL_TEST_FAILED,也就是「對方回 204 沒有」這一個位元,加上回應快慢。這叫盲打(blind)SSRF——可以拿來探測「哪台機器活著、哪個埠開著」並觸發有副作用的端點,但偷不走資料。面板正是據此把研究員報的 MEDIUM 下修為 LOW。
測試路徑要 notify_config.update + 帳號層 super admin。但存起來的那條路徑只要 notify_config.update:
PUT NOTIFY_CONFIG/DISCORD 把內網 URL 存成 secret,enabled=true
→ 之後每一次工作流事件或檢測完成要發通知時
→ app/notification/service/notification_service.py:90 就會去打那個位址
這條的權限門檻更低,而且是自動反覆觸發的(每次通知都打一次),不需要攻擊者手動按測試。只修測試端點等於沒修。
在使用前驗證 webhook URL,且寫入路徑與測試路徑都要套:
discord.com / discordapp.com)這四條不在本棒的 18 個目標檔案內。 研究員為了確認可達性讀到範圍外,密鑰專項檢查順帶撈出來的。工具自己標了 out of scope。 它們是真的、值得處理,但它們的存在不構成「那幾棵樹被掃過」——
.env.test、docs/、scripts/從未被當成掃描目標。
.env.test 內的四把對外 API 金鑰【已處理】面板:3/3 全票 · 位置:.env.test 四行
是什麼:.gitignore 排除了 .env.*,卻又特地寫了一行 !.env.test 把它放回來,理由註明「這是範本、沒有真憑證」。實際上不是——裡面是四把完整長度、符合各家格式的對外 AI 服務 API 金鑰。檔案在 HEAD 受版控,且已存在於 origin/main 與另外十一個推上去的 branch。
出事會怎樣:任何拿到 repo 的人(承包商、外流的 CI 快取、鏡像)都能用貴公司帳號呼叫這些服務——直接的財務損失;還能讀走服務端存的執行軌跡,那裡面例行含有應用提示詞與客戶資料。不需要任何網路位置,讀得到 repo 就是全部的門檻。
決策者於掃描當天將四把金鑰全數撤銷並重新簽發,檔案已撤出版控(commit 5746cef1 / 56c1dc43)。
財務與資料風險已解除——即使舊字串仍散落在歷史紀錄裡,那些金鑰已經不能用了。
面板:3/3 全票 · 位置:.env.test 四行
是什麼:同一個 .env.test 裡還釘死了 JWT 簽章密鑰,以及資料庫、Redis、MinIO 的密碼(都指向具名的內部主機)。
出事會怎樣:JWT 密鑰最危險——拿到就能自己偽造一張「我是管理員」的登入憑證,這條完全不需要網路位置,只要打得到目標 API 就行。DB 密碼則能直接 psql 進去讀租戶資料,繞過所有應用層授權;註解掉的那一行還是 cmmgr,那是繞過 RLS 的系統管理員帳號。
同一組密碼也出現在 F4 的腳本裡,代表密碼被跨環境重複使用——一處外洩、多處受害。
與 F2 同批處理:值已輪替、檔案已撤出版控(commit 5746cef1 / 56c1dc43)。
面板:3/3 全票 · 位置:docs/system-design/scripts/generate_db_schema_docx.py:34
是什麼:一支產生資料庫結構文件的腳本,把完整的連線設定(主機、埠、資料庫名、cm_app 密碼)直接寫死在程式碼裡,指向 POC 環境。
出事會怎樣:讀得到 repo 又連得到那台機器的人,複製那個連線設定就能直接 psql 進 POC 資料庫——專案自己的 CLAUDE.md 就寫明「POC 等同 production」(對外 demo、客戶試玩)。讀得到客戶的展示資料,視 RLS 而定可能也改得到。
建議修法:改從環境變數讀(config/config.py 早就有 DB_HOST / DB_USER / DB_PASSWORD),輪替該密碼,並清掉 git 歷史裡的字面值。
對應卡:CM-1608(與 F8 併卡——同一類「腳本內硬編密碼」,且需要同一批輪替動作)。
面板:3/3 全票(於第二輪完整面板驗出,即被額度中斷後交棒重驗的那一條) 位置:
scripts/e2e_test_module_frame_with_docx.py:38 — 沒有環境變數逃生門,寫死就是寫死scripts/migrate_2026-08-09_fr062_existing_tenant_licenses.py:80scripts/seed_2026-08-01_fr059_detection_profiles.py:65是什麼:blsadmin 這個系統管理員帳號的登入密碼,以字面值出現在三支腳本裡。
後兩支特別要注意寫法:它們是 os.getenv("...", "<真密碼>")——環境變數沒設時,default 就是真密碼,而且會靜默使用。這種寫法看起來像「有做環境變數化」,實際上在最常見的情境(沒人記得設那個變數)下,用的就是硬編的那組。
出事會怎樣:拿到 repo 就等於拿到一組可用的管理員帳密。拿去登入任何還在用這組密碼的環境(很可能包含 DEV,也可能包含用同一套 runbook 佈建的其他環境),立刻取得管理員 session。
🔴 而管理員正好有權改 SMTP 設定——這條可以直接接上 F1。 兩條合起來是完整的憑證竊取鏈:拿 repo → 用硬編密碼登入 → 用 F1 的測試信端點把平台 SMTP 憑證導到自己機器。
建議修法:三支腳本全部拔掉字面值,改成沒有 default 的環境變數(沒設就報錯退出,不要靜默 fallback);在所有仍在使用該密碼的環境上輪替 blsadmin。
對應卡:CM-1608(與 F4 併卡)。
這是工具沒報、首腦另外查出來的,範圍比 F2 描述的更廣。
F2 那批金鑰的字串本身,除了 .env.test 之外,還出現在版控裡的 15 個檔案(其中 13 個受版控):
| 位置 | 檔數 | 說明 |
|---|---|---|
docs/conversation-history/ |
13 | 對話紀錄原始 dump,當時把含金鑰的檔案內容整段帶進去了 |
docs/security-reports/2026-07-25/trivy-backend.jsondocs/security-reports/2026-07-25/trivy-backend-final.json |
2 | 諷刺的是——這是資安掃描報告本身把撈到的憑證原文記了下來 |
其中 9 個檔含的是與 .env.test 完全同一把。
沒有財務風險。 那些金鑰在 2026-09-08 已被決策者全數撤銷,字串留著也叫不動任何服務。
但它會污染未來每一次掃描。 每一輪資安掃描的密鑰檢查都會再撈出這 15 個檔,每一次都要有人重新判斷「這是不是真的」——而判斷成本不低(要去查那把金鑰是否已撤銷)。留著等於每次掃描都製造一批必須人工排除的假陽性,久了就會養出「掃到憑證先當假的」的壞習慣,真正的那次就會被漏掉。
trivy-backend*.json 那兩個檔還有個額外的教訓:掃描工具的產出本身可能含憑證原文,歸檔前要過濾。
對應卡:CM-1607。
母卡「首腦讀碼後的重點面」列了四個宿主側方向,本次實質觸及兩個:
| 母卡重點 | 本次狀態 |
|---|---|
| ⑧ 測試寄信把既存 SMTP 密碼送到使用者指定的伺服器 | ✅ 確認為問題(F1)——正是這一棒開來找的東西 |
| ⑨ Discord / Telegram 的同款回填模式 | 部分——Discord 從 SSRF 角度掃到了(F5),但「回填的 secret 本身會不會外洩」這個角度(webhook URL 本身即機密、Telegram token 拿到即可冒充 bot)未觸及 |
| ⑩ 背景 thread 寄信的租戶設定讀取是否 fail closed | 未觸及——無結論。多處在背景 thread 呼叫寄信、無 user context 時實際走哪條讀取路徑,本次沒有答案 |
⑪ 繞 RLS 的設定直讀(system_config_root_reader.py)的呼叫點盤點 |
未觸及——該檔在掃描範圍內,但沒有系統性的呼叫點盤點結論 |
⑨⑩⑪ 三條的空白必須讀作「沒有結論」,不是「沒有問題」。 ⑩ 尤其值得追——「A 租戶的信用 B 租戶的郵件伺服器寄出去」屬跨租戶洩漏,嚴重度天然不低,而它現在是完全空白的。
CLAUDE-SECURITY-* 執行目錄(自帶單行 * 的 .gitignore,不入版控)verification.status: verified,findings.total: 6(high 2 / medium 3 / low 1),verification_runs: 2,unreviewed_candidate_sites: 0,lostCandidates 空