掃描日期:2026-09-08 起跑,2026-09-09 完成(跨兩次額度重置) 掃描版本:compliance-manager-be
feature/FR-075@582cbdfd(工作目錄有未 commit 變更) 工具:Claude Codeclaude-securityplugin(claude-security:scanworkflow) 範圍:六條路徑、33 個受版控檔案——api/bulletin/、app/bulletin/、domain/bulletin/、infra/bulletin/、di_containers/bulletin/、di_containers/dashboard_apis/bulletin.pyeffort:low,focus:attack-surface模型:主 session Opus 5 (1M context),研究員繼承 狀態:✅ 驗證面板完整跑完(分兩輪+一次續跑),stamp 為verification.status: verified——十條全部 3/3 全票,unreviewed_candidate_sites: 0對應卡片:CM-1610(母卡 CM-1609)
範圍內找到 4 條問題,母卡標的十個重點裡有六個拿到明確結論。 最值得看的是 F13:AI 儀表板有一條不經過 route 層守門的旁路,任何登入者都能透過它讀到全租戶的公告,包含未發布的草稿——而且那些內容還會被送到第三方 LLM。另外 6 條是密鑰專項撈到的範圍外發現(版控裡的硬編憑證),其中 F12(JWT 簽章金鑰)與 F15(installer 原廠 super admin 密碼)值得單獨看。
先看這張表就好,每條的完整說明、程式碼行號與建議修法在後面各自的章節(點 ID 跳過去)。
| # | 嚴重度 | 這是什麼問題(白話) | 出事會怎樣 | 要先有什麼才打得到 | 位置 | 修正卡 |
|---|---|---|---|---|---|---|
| F13 | 🟡 MEDIUM | AI 儀表板可以直接呼叫「取公告列表」這支程式,這條路不經過網頁那層的任何守門。而部門過濾的開關預設是關的 | 任何登入者叫出 AI 儀表板、要它「列出所有公告」,就拿到全租戶的公告——含別部門的、已停用的(草稿)、還沒到發布時間的。而且前三筆會原文送給第三方 LLM | 一個登入帳號(不需要任何公告相關權限)+租戶有買 ai-dashboard 模組 |
bulletin_service.py:111dashboard_apis/bulletin.py:10-19 |
✅ 已修(CM-2038;SUMMARY M17-2) |
| F16 | 🟡 MEDIUM | 有「編輯公告」權限的人,可以改或刪掉任何一則公告,包含別部門發的。系統只問「你有沒有編輯權」,從不問「這則是不是你的」 | 別部門的公告被竄改或刪除。公告是「發給大家看、大家會相信」的內容。另外:更新時若沒帶部門欄位,會靜默清空這則公告的發送對象 | 一個有 bulletin.update 或 bulletin.delete 的角色(部門公告編輯者就是這種)+知道目標公告的 uid |
bulletin_service.py:162(改)bulletin_service.py:180(刪,連 user 都沒收)bulletin_service.py:165(清空對象) |
✅ 已修(CM-2026;SUMMARY M17-1) |
| F17 | ⚪ LOW | 用網址直接開一則公告時,系統不檢查任何東西——不看部門、不看是不是草稿、不看有沒有到發布時間、不看是不是已刪除 | 調部門之後,舊部門的公告用舊網址還是打得開;還沒公告的公告可以提前看到。只有 uid 不可猜這件事在擋 | 一個登入帳號 + 從別處知道 uid(uid 是 uuid4 猜不到,但會出現在列表回應與前端網址上) | bulletin_service.py:119 |
✅ 已修(CM-2026;SUMMARY M17-3) |
| F18 | ⚪ LOW | 公告列表的部門過濾,碰到「沒有部門」的帳號會直接不過濾、全部給你看。而且要不要過濾的開關,是呼叫端自己在請求裡傳的 | 沒有部門的帳號打列表,拿到全租戶公告(含草稿、含過期),以及它們的 uid——這些 uid 正好餵給上面 F17 那條 | 一個登入帳號 + 該帳號在當前租戶沒有部門(org_units=[],或切到沒有部門的租戶) |
bulletin_service.py:79(判斷式)bulletin_route.py:29-35(開關來自請求) |
✅ 已修(CM-2026;SUMMARY M17-4) |
| F12 | 🟡 MEDIUM ⚠️ 範圍外 |
簽發登入憑證用的那把金鑰,明文躺在版控的對話紀錄裡,34 處。經比對與現行 .env 完全一致,是還在用的金鑰 |
拿到這把金鑰就能自己偽造任何人的登入憑證——任何使用者、任何租戶、任何角色。不需要密碼、不需要 MFA。租戶隔離(RLS)也是從憑證推出來的,一併失效 | 只要讀得到 repo,不需要任何網路位置 | docs/conversation-history/2026-05-20/ssp-import-export-phase2/1802c4fb-a3-writing-plans.md:6908 |
✅ 已修(CM-2048;原「併 CM-1607」作廢) |
| F15 | 🟡 MEDIUM ⚠️ 範圍外 |
安裝程式在每一套部署都種下同一組最高權限帳號密碼(bcrypt hash 寫死在 SQL 裡),而且不要求首次登入改密碼 | 把那個 hash 拿去離線爆破一次,就拿到所有客戶安裝的 super admin。這個帳號還被 root_admin_guard.py 特別保護、刪不掉停不掉 |
讀得到 repo(拿 hash)或拿到廠內密碼文件 + 連得到某套部署的登入端點 | scripts/init/06-admin.sql:41-42(hash):69-82(寫入) |
🚫 裁定不修(原廠系統殼帳號,密碼由原廠保管;SUMMARY M17-7) |
| F1 | 🟡 MEDIUM ⚠️ 範圍外 |
資料庫管理帳號 cmmgr 的密碼寫在文件裡。經比對與 .env 一致、是活的。**這個帳號是 BYPASSRLS |
直接連進 DEV 與出貨基線庫**,繞過所有租戶隔離。基線庫是出貨映像檔的來源,寫進去會流進客戶 | 讀得到 repo + 連得到 192.168.50.188:25432(內網或 VPN) |
docs/analysis/2026-05-28-poc-db-migration-plan.md:73 |
✅ 已修(CM-2048) |
| F2 | 🟡 MEDIUM ⚠️ 範圍外 |
OpenAI/Anthropic/Google/LangChain 的 API 金鑰,完整躺在九個對話紀錄檔裡 | 用公司帳號燒錢;LangSmith 那把還能讀走歷史追蹤紀錄,裡面例行含應用提示詞與客戶資料 | 讀得到 repo。(commit 5746cef1 宣稱已於 2026-09-08 撤銷,但那次只刪了 .env.test,這九個檔沒動,且撤銷與否無法從程式碼查證) |
docs/conversation-history/2026-04-28-to-04-30-survey-answer-arc/part-verbatim-01-of-03.md:3651 等九檔 |
✅ 已修(CM-2048) |
| F3 | 🟡 MEDIUM ⚠️ 範圍外 |
私有套件庫 Nexus 的帳密、與 MinIO 的金鑰,寫在一份交接文件裡 | 這是供應鏈的根——有 Nexus 發布權就能推一個含後門的 jedi-common,下次 build 自動裝進來執行。MinIO 那把能讀寫稽核證據桶 |
讀得到 repo + 連得到 192.168.50.171 + 該帳號仍有發布權 |
docs/features/FR-039-2606-distributed-file-agent/handoff/2026-06-18-FR039-handoff.md:89 |
✅ 已修(CM-2048) |
| F14 | 🟡 MEDIUM ⚠️ 範圍外 |
一支 migration 腳本寫「環境變數沒設就用這個密碼」——而那個「這個」是真的管理員密碼 | 拿到 repo 就有一組可用的管理員帳密。變數忘了設時靜默用真密碼登入,操作者不會被告知 | 讀得到 repo + 連得到某個該帳號仍用這組密碼的環境 | scripts/migrate_2026-08-09_fr062_existing_tenant_licenses.py:80 |
✅ 已修(CM-2048),腳本改讀環境變數 |
驗收時對兩條範圍外發現做了獨立查證,結論與報告原判不同,以本段為準:
F12(JWT 簽章金鑰)→ 降為衛生類,不獨立開卡,併入 CM-1607。(現況:CM-1607 已作廢,殘留字串已由 CM-2048 清掉。)
報告判 MEDIUM、首腦驗收時一度主張升 HIGH(理由:實測該金鑰與現行 .env 一致、git grep 命中本機 30 檔/origin/main 31 檔、橫跨 14 個 commit,最早由 45d5b8ac 帶入)。但決策者提出的問題推翻了這個判級:安裝時是不是動態產生?
查證結果——是。scripts/installer/install.sh:1252 每套安裝各自跑 JWT_SECRET_KEY="$(gen_key)",而 gen_key(:211)是 openssl rand -base64 32,密碼學等級隨機;同區塊的 DRIVE_TOKEN_ENCRYPTION_KEY/DETECTION_TOOL_ENCRYPTION_KEY 同理。變數名也對得上:config/config_loader.py:81 讀的正是 JWT_SECRET_KEY(平鋪格式,兼容舊的 JWT_SECRET.jwt_secret JSON),不是死參數。
所以外流的是開發機那把,不是任何客戶的。 影響範圍從「所有客戶安裝」縮到「我們自己的 DEV」,觸發前提多一道「連得到 DEV(內網/VPN)」。這與 CM-1608 的裁決形狀相同(「那是自己的測試機,密碼跟部署的無關」),適用同一判準。
處置:版控裡那 31 個檔的字串仍要清,但性質是打掃不是堵漏——留著會讓往後每次掃描都撈出這批假陽性(正是 CM-1607 的開卡理由)。故併入 CM-1607,不另開卡。DEV 那把金鑰決策者裁不需更換。
教訓(寫給後續棒次):判密鑰外洩的嚴重度,「這把是不是活的」與「散在幾個檔」都不是決定性的那一問,「客戶端用的是不是同一把」才是。首腦驗收時查了前兩者就升級,漏了第三者,由決策者當場問出來。下次遇到任何硬編/外洩憑證,先查 installer 有沒有動態產生。
F15(installer 原廠 super admin 密碼)→ 維持記錄,暫不調整。(現況:🚫 裁定不修,SUMMARY M17-7——原廠系統殼帳號,密碼不交給客戶。)
決策者 2026-09-09 裁:「目前應該是都一樣,這個先記錄起來就好,之後看怎麼調整。」確認現況即每套安裝種同一組,不強制首次登入改密碼。與 F12 相反——JWT 金鑰動態產生了,這個沒有。本 arc 不開修正卡、不派工,留待日後一併處理安裝期憑證策略。後續定案為裁定不修(見上)。
被面板否決的一條:.env.sample 裡的 Cloudflare Turnstile 測試金鑰,面板 0:2 判為 false positive——installer 寫死 TURNSTILE_ENABLED=false,沒有任何出貨路徑會走到那個值。判得對,不列入。
「這十條存在嗎」與「只有這十條嗎」要分開回答。
存在:可信度高於前幾棒。 十條全部 3/3 全票通過三人對抗式面板(reachability/impact/defenses 各一票),verification.status: verified、unreviewed_candidate_sites: 0、incomplete_panel_candidates: 0、lostCandidates 空。沒有任何一條是靠部分票通過的——這一點在中途兩次撞額度之後仍然成立,過程見下方「中斷與續跑」段。四條範圍內的發現,首腦另已逐條開檔核對行號與程式碼形狀(見各章節末的核對註記),全部屬實。
「只有這十條」:不可信,兩層。
第一層是通例:low effort 單次快篩,沒有 inventory、沒有威脅模型、沒有廣度掃蕩,工具自己把 completenessCheckOutcome 標成 not-applicable。33 檔裡有 10 個是空的 __init__.py,實質內容 23 檔,範圍很小,但小不等於掃得深。
第二層是這一棒特有的:六條範圍外發現不代表那些目錄被掃過。 F1/F2/F3/F12/F14/F15 是密鑰專項順帶撈到的,docs/、scripts/ 兩棵樹從來不是掃描目標。這六條不能讀成「那兩處只有這些問題」——那兩處等於沒掃過。
另外,本輪 coverage.research 是 null,沒有機器產生的「哪些檔真的被讀到結論」清單。範圍內含哪 33 檔是確定的,每一檔是否被讀完則無獨立佐證。
同樣地,掃描過程沒有執行任何被掃的程式碼——沒跑測試、沒發攻擊、沒拿任何憑證去連任何服務。所有結論都來自讀原始碼。
| 項目 | 數字 |
|---|---|
| 研究員派出 / 回報 | 2 / 2 |
| 候選發現 | 13 條原始 → 去重後 11 條 |
| 面板票數 | 38 |
| 面板審過的發現 | 10(panel_reviewed_findings: 10,panel_quorum_findings: 10) |
| 未投票候選 | 0(unreviewed_candidate_sites: 0) |
| 面板不完整候選 | 0(incomplete_panel_candidates: 0) |
| 驗證輪次 | 2(+一次同輪續跑) |
| 被面板降級 | 4 條(F1、F12 HIGH→MEDIUM;F17、F18 MEDIUM→LOW) |
| 被面板否決 | 1 條(Turnstile 測試金鑰,0:2) |
| 總耗時 | 約 14 小時 8 分(50,871 秒,含等待兩次額度重置) |
這一棒的過程比結果更值得記下來。 前後撞了兩次 session 額度上限,還踩到一個工具本身的邊界情況,三者都沒讓結果失真,但第三個差一點。
第一輪面板驗到一半撞上額度上限(凌晨 5:20 重置那次),11 個候選裡有 8 個面板不完整——其中 4 個是零票、2 個一票、2 個兩票。工具沒有拿部分票充數,全部記進 adversarialCasualties 並交棒下一輪:
F4: panel incomplete (2/3 voters returned), handed to the next verification run
F6: panel incomplete (0/3 voters returned), handed to the next verification run
...(共 8 條)
這次中斷之前,研究階段已經完整跑完(2 派 2 回),所以 33 檔是真的讀過了、候選清單完整。斷的全是投票。
額度重置後跑第二輪,續驗那 8 條。又撞一次額度(上午 10:20 重置那次),這次是 4 條沒湊滿票,而工具這輪的處置是 dropped without a verdict——直接丟棄,因為沒有下一輪可交棒。
那 4 條裡有 3 條的已投票數是全員 TRUE_POSITIVE,且其中兩條正是母卡重點 ③ 與 ⑤。如果就這樣出報告,這兩個重點會在報告上留白,正是母卡明文警告的「不要留白讓人誤以為乾淨」。
處置:額度重置後用 resumeFromRunId 續同一個 run,已完成的 19 票從快取回放、只重派掛掉的 5 票(C8:v2、C9:v1、C9:v2、C10:v3、C11:v3)。25 個 agent 全數回報、零失敗。票數仍由工具程式碼計算,不是人工拼湊。
續跑完成後把結果併回 run 目錄,save_result.py 回報 7 findings 而不是預期的 10 條。
原因:run 目錄已經停在 shard 2,而完整版續跑回來的也是 shard 2。merged() 判定 scan.coverage.verificationRun == run.chain.shard 成立,於是保留了先前那份殘缺的 run 2、丟棄完整版。
為什麼難發現:兩份結果的 finding ID 互相衝突且意義不同——殘缺版的 F15 是「單筆讀取不做檢查」,完整版的 F15 是「installer 原廠密碼」。光看數字對不出問題,要逐條比對標題才看得出來。
修法(沒有手改任何紀錄):備份 run 目錄 → 刪掉 findings.json/votes.json/coverage.json/candidates.2.1.json 退回 run 1 之前 → 用 save_result.py 重放 run 1 的原始 output → 再折入完整 run 2 的 output。三份紀錄與票數全程由工具產生。
給後續棒次的教訓:
save_result.py 印的數字。它印「recorded」不代表它折進去了。save_result.py 的 output 檔內含 runDir 絕對路徑,不能換目錄重放(試過會被拒絕)。要重建只能在原地做,所以動手前先備份 run 目錄。母卡列了十個重點,明文要求「逐項回答有沒有結論,沒觸及的要明講」。六項有明確結論、兩項部分回答、兩項本棒未觸及。
| # | 重點 | 結論 |
|---|---|---|
| ① | 兩條讀取 route 沒有 capability 守門 | ✅ 確認是漏守門,不是設計如此。見下方專節,這是本棒最硬的一項查證 |
| ② | 單筆讀取完全不做範圍檢查 | ✅ 確認,即 F17。嚴重度依 B1 的 uuid4 結論下調為 LOW |
| ③ | 列表部門過濾反直覺、開關由呼叫端傳 | ✅ 確認,即 F18。三條路都追了,見該節 |
| ④ | 查詢條件「拿物件欄位當白名單」動態組裝 | ⚠️ 部分回答。工具沒有把它報成獨立發現,但 F13 的成因正是 BulletinQueryEntity.__init__ 把 org_unit_id 吞進 <strong>kwargs 而不轉成過濾條件。「外部鍵能不能落進 __dict__ 進而改變查詢語意」這條完整路徑未經獨立查證** |
| ⑤ | 寫入只驗 capability、不驗歸屬 | ✅ 確認,即 F16。delete_bulletin(uid) 確實連 user 參數都沒有(:180) |
| ⑥ | 更新時先刪光部門關聯再重建 | ✅ 確認且打得到。:165 無條件 delete_by_bulletin_id,之後才看 kwargs.get("org_units")。已併入 F16 的影響描述 |
| ⑦ | content 是不驗證的 Raw 欄位(儲存型 XSS) |
❌ 本棒未觸及。工具沒報,且 FE 渲染方式需開 FE repo 查證才能下結論——母卡明文要求「不要憑猜報」,故不猜。這一項等於沒查 |
| ⑧ | AI 儀表板的旁路入口 | ✅ 確認,且是本棒最重要的發現,即 F13 |
| ⑨ | 審計欄位的 SQL 子查詢 hybrid_property | ❌ 本棒未觸及。工具沒報,首腦亦未獨立查證跨 users 表的 RLS 繞過問題。這一項等於沒查 |
| ⑩ | DB 現況(RLS 是否 enabled、四動作對稱性) | ✅ 已查證,見下方專節。有一個母卡沒預期到的發現 |
bulletin.read 存在、被設計、也被設定了——只是沒有任何 route 執行它母卡說「判準不要只看程式碼註解——查 DB 的 capability 目錄裡有沒有 bulletin.read 這個項」。查了,答案比預期明確:
能力點存在(scripts/init/04-seed-core.sql:218):
INSERT INTO public.capabilities (id, name, ...) VALUES (3, 'bulletin.read', 'bulletin', 'read', 'bulletin read access', false)而且被綁到公告管理頁的路由上(04-seed-core.sql:359-362,route_id=7 即 bulletin-manage):
VALUES (7, 3, 'ALL') -- bulletin.read,ALL 規則
VALUES (7, 1, 'ANY') -- bulletin.create
VALUES (7, 4, 'ANY') -- bulletin.update
VALUES (7, 2, 'ANY') -- bulletin.delete系統設計文件甚至明文寫了判定規則(docs/system-design/scripts/generate_permission_system_docx.py:471-479):
使用者必須有
bulletin.read(ALL 規則)/ 加上 create、update、delete 任一(ANY 規則) read 一律設 ALL:必有讀權限才能看到選單;缺就完全不可見
但 bulletin.read 這個字串在整個 Python 程式碼裡,除了那份文件產生腳本以外,一次都沒出現。 兩條讀取 route 只掛 @jwt_required()。
結論:這不是「設計成全體登入者可見」,而是明確的漏守門。 能力點被定義、被綁定、被寫進設計文件,唯獨沒有被任何一行程式碼檢查。這與 CM-1585/CM-1589 完全同型。
補充:另有 route_id=6(bulletin-list,一般使用者的公告列表頁)綁的是 bulletin-list.read(capability id 82),是另一個能力點,同樣沒有任何程式碼檢查它。
bulletins 的 RLS 齊全,但 bulletin_org_units 完全沒有 RLS母卡要求唯讀查證兩張表。以出貨 schema(scripts/init/02-schema.sql)查證結果:
bulletins:✅ RLS 已啟用(:26800),四個動作的 policy 齊全(:26806/26813/26820/26827)。INSERT policy 確實與其餘三支不對稱——它多了 app_org_allowed_for_session(org_unit_id) 這個條件,方向是更嚴格,屬設計而非缺陷(與 B1 移交的線索③ 一致)。
bulletin_org_units:🔴 整張表沒有 ENABLE ROW LEVEL SECURITY,也沒有任何 policy。 全檔只有 11 處提到這張表,全是 CREATE TABLE、PK 與兩條 FK。
現況:🚫 裁定不修(SUMMARY M17-5,靠 bulletins 主表擋)。
這正是母卡提到的 CM-1559 形狀(「有 policy 但 RLS 沒啟用」)的更極端版本——這張表連 policy 都沒有。實際影響有限:這是關聯表,只存 (bulletin_id, org_unit_id) 兩個整數,跨租戶讀到也只是一組數字,且 bulletins 本身的 RLS 會擋住實際內容。但它意味著**「公告發給哪些部門」這個關聯本身不受租戶隔離保護**,且與 F16 的「靜默清空對象」組合時,DB 層沒有第二道防線。
這一項是母卡沒預期到的(母卡只要求確認「是否 enabled、四動作是否對稱」,預期的答案形狀是「有或沒有」,不是「整張表不在保護範圍內」)。
現況:已修(CM-2038)。
面板:3/3 全票|信心:high|位置:app/bulletin/service/bulletin_service.py:111
白話:公告有兩條路可以讀。一條是網頁走的 route,那條至少還有登入檢查;另一條是 AI 儀表板走的,它直接呼叫 service,完全不經過 route 那層。而部門過濾的開關在這條路上永遠是關的。
技術細節:di_containers/dashboard_apis/bulletin.py:10-19 把 bulletin.get_bulletins 註冊給 jedi-ai-dashboard。DataAPIService._execute_api_call 呼叫它時傳 params={},所以 query_entity.auth 是 falsy,:107 的 if query_entity.auth and user.org_unit_id 不成立,:111 的查詢就只帶 is_delete=0 跑出去,回傳全租戶公告。
註冊檔宣告的 optional_params: [title, status, org_unit_id] 有誤導性——org_unit_id 傳進去會被 BulletinQueryEntity.__init__ 的 <strong>kwargs 吞掉,不會變成過濾條件**(這正是重點 ④ 的形狀)。
額外影響:AIDashboardAppService._ask_ai_to_design_layout 會把結果的前三筆原文送給設定的第三方 LLM。所以這不只是越權讀取,還是資料外流。
觸發前提:一個登入帳號(不需任何 bulletin.* 能力點)+ 租戶有 ai-dashboard 授權 + 第一階段的 LLM 選中這支 API(攻擊者用自由文字 prompt 誘導)。
建議修法:不要依賴呼叫端傳的 auth 旗標。讓 get_bulletins 無條件從 get_user_context() 推導可見範圍(部門 + enable=1 + 發布/過期時間窗),與 get_bulletins_and_pager 在 auth 為真時的行為一致;並在 dashboard 註冊路徑補上 bulletin.read 檢查。
首腦核對:✅ 已開檔核對。:107 判斷式、:111 查詢行、註冊檔 :10-19 三處均與報告一致。
現況:已修(CM-2026)。
面板:3/3 全票|信心:medium|位置:app/bulletin/service/bulletin_service.py:162
白話:系統只問「你有沒有編輯公告的權限」,從來不問「這一則是不是你的、是不是你部門的」。有權限的人可以改或刪任何一則。
技術細節:uid 來自網址路徑,唯一的守門是 route 層的 @require_capability("bulletin.update")(bulletin_route.py:55)與 @require_capability("bulletin.delete")(:67)。update_bulletin 拿到 uid 直接組 entity 更新(:158-162)。delete_bulletin(self, uid)(:180)連 user 參數都沒收,所以就算想檢查也無從檢查。
連帶的靜默清空(母卡重點 ⑥)::165 無條件 delete_by_bulletin_id(bulletin.id),之後才 kwargs.get("org_units")。沒帶 org_units 的部分更新會把發送對象清空,公告可見範圍因此改變。與 CM-1588 同類。
觸發前提:一個有 bulletin.update 或 bulletin.delete 的角色 + 知道目標 uid。seed 只把這些能力點給 Administrator,但角色矩陣允許租戶管理員自建這種角色(部門公告編輯者就是典型)。
建議修法:resolve 出公告之後,在 service 層斷言操作者是建立者、或與該公告的 org_units 有交集,再套用更新或刪除;把操作者傳進 delete_bulletin 以便做同樣斷言。部門關聯的重建改為只在有帶 org_units 時才動。
首腦核對:✅ 已開檔核對。:162、:165、:180 三處與報告一致;delete_bulletin 的簽章確認只有 (self, uid)。
現況:已修(CM-2026)。
面板:3/3 全票|信心:high|位置:app/bulletin/service/bulletin_service.py:119|面板降級:MEDIUM → LOW
白話:用網址直接開一則公告,系統什麼都不檢查——不看部門、不看是不是草稿、不看到沒到發布時間、不看是不是已刪除。列表那條費心做的過濾,在這條路上完全繞過。
技術細節:BulletinRoute.get(bulletin_route.py:46-49)只掛 @jwt_required(),把 uid 直接給 get_bulletin(uid),後者呼叫 get_bulletin_by_uid,最終是 BaseRepositoryImpl.get_by_uid 的 filter_by(uid=_uid).first(),沒有任何範圍述詞。
🔴 嚴重度依 B1 結論調整:母卡原本擔心 uid 可枚舉。B1 已確認 generate_uuid 用 uuid.uuid4(),122-bit 密碼學隨機、不可預測,所以列舉不可行。真正的風險路徑是**「uid 從別處洩漏」**——而 uid 確實會出現在列表回應與前端網址(/bulletin/bulletin-view?uid=)上,且 F18 那條會讓沒有部門的帳號一次拿到全部 uid。面板獨立得到同樣判斷,三票一致降為 LOW。
攻擊情境:使用者從 A 部門調到 B 部門,手上還留著 A 部門公告的 uid(舊的列表回應、或收藏的網址)。調動後列表已經不回傳那則,但 GET /api/1.0/bulletin/<uid> 照樣回傳完整內容。同樣的請求也能讀到 release_time 還沒到的公告。
建議修法:resolve 出公告後,套用與列表相同的可見性述詞(部門或建立者 + enable=1 + is_delete=0 + 發布/過期時間窗)再回傳。
首腦核對:✅ 已開檔核對。:119 與 bulletin_route.py:46-49 一致,route 確實只有 @jwt_required()。
現況:已修(CM-2026)。
面板:3/3 全票|信心:medium|位置:app/bulletin/service/bulletin_service.py:79|面板降級:MEDIUM → LOW
白話:公告列表的部門過濾寫成一個 if/else。if 要兩個條件同時成立才過濾;else 又只在「有部門」時才限縮成本人建立的。所以「沒有部門」的帳號兩邊都不套過濾,直接看到全部。而且要不要過濾的開關,是呼叫端自己在請求 body 裡傳的。
母卡要求追的三條路,逐條回答:
(a) auth 傳 false 走 else 分支會看到什麼:若使用者有部門 → 看到自己建立的公告(owner_created_user 限縮,:88)。若沒有部門 → 看到全部。
(b) user.org_unit_id 為 None 的是哪種帳號:UserContextDTO.org_unit_id 是 org_units[0].id if org_units else None(依當前租戶過濾後取第一個),且 users.org_unit_id 可為 NULL。所以建立時 org_units=[] 的帳號、或切換到自己沒有部門的租戶的帳號都會是 None。註解說的「顯示全部公告」範圍就是整個租戶。
(c) 兩者組合能不能讓一般使用者看到全部:能。只要帳號沒有部門,不論 auth 傳什麼都看得到全租戶公告,含 enable=0 草稿與過期項;再傳 filters.is_delete = 1(schema 接受的欄位)還能撈出軟刪除的。
關於母卡提醒的 marshmallow default=:母卡提醒「default= 是序列化預設值、不是反序列化預設值」。實測結果是這一點不影響結論——auth 完全不傳時 BulletinQueryEntity 收到的是 falsy,走 else 分支,與傳 false 同路。真正的問題不在 auth 的預設值,而在兩個分支都沒有涵蓋「沒有部門」這個情況。
建議修法:把「沒有部門」視為 fail-closed 而非 fail-open;可見性模式由伺服器依呼叫的 route 判定,不要從請求 body 的布林值取;管理檢視也應套用 enable/發布時間窗過濾,而非只在 auth 路徑套。
首腦核對:✅ 已開檔核對。:79/:88/:90 與報告一致;bulletin_route.py:29-35 確認 filters 由 request.get_json() 手動 load() 後原樣傳入。
面板:3/3 全票|面板降級:HIGH → MEDIUM|位置:docs/conversation-history/2026-05-20/ssp-import-export-phase2/1802c4fb-a3-writing-plans.md:6908
簽發登入憑證的金鑰(JWT_SECRET → config/config_loader.py:81 → Config.JWT_SECRET_KEY)明文躺在對話紀錄裡,共 34 處。面板比對過與工作目錄 .env 完全一致,是現行金鑰不是舊樣本。
拿到它就能偽造任何使用者、任何租戶、任何角色的憑證,不需密碼或 MFA;build_user_context() 會據此產生 allowed_tenant_paths,RLS 也一併失效。只要讀得到 repo 就能做,不需任何網路位置。
修法:每個環境輪替 JWT 金鑰、視為已燒毀;加 pre-commit 密鑰掃描;對話紀錄歸檔前必須遮罩 .env 內容。
面板:3/3 全票|位置:scripts/init/06-admin.sql:41-42(hash),:69-82(寫入)
init.sh 每次全新安裝都會跑的 06-admin.sql,插入一個 is_super_admin 的 admin 帳號,bcrypt hash 與 salt 是寫死的常數。所以每一套客戶安裝都是同一組密碼,而且那份密碼材料在版控裡。這個帳號還被 root_admin_guard.py 特別保護(刪不掉、停不掉),且 seed 不建立強制改密碼的要求。
離線爆破那個 hash 一次,就拿到所有安裝的 super admin。
修法:改為每套安裝各自產生密碼(install.sh 對 DB 帳號已經這樣做了),把 hash 傳進 seed 而不是寫死;並要求首次登入強制改密碼。
cmmgr 密碼在版控裡面板:3/3 全票|面板降級:HIGH → MEDIUM|位置:docs/analysis/2026-05-28-poc-db-migration-plan.md:73
cmmgr 與 cm_app 的密碼明文寫在數十份文件裡,面板比對過與 .env 的 DB_PASSWORD 一致、是活的。scripts/init/00-cluster.sql:31 定義 cmmgr 為 BYPASSRLS,所以拿它連線等於繞過全部租戶隔離。該庫又是 scripts/init/gen_*.sh 的 dump 來源,寫入會流進出貨基線。
修法:所有主機輪替 cmmgr/cm_app 密碼;文件裡的字面值改成專案 CLAUDE.md 已規定的「請查 .env」寫法;歷史清除或視為已燒毀。
面板:3/3 全票|位置:docs/conversation-history/2026-04-28-to-04-30-survey-answer-arc/part-verbatim-01-of-03.md:3651 等九檔
完整的 sk-proj-…/sk-ant-api03-…/AIzaSy…/lsv2_pt_… 逐字出現在九個受版控 markdown 裡。commit 5746cef1 的訊息宣稱這四把已於 2026-09-08 撤銷,但那次清理只刪了 .env.test,docs/conversation-history/ 下這九份至今仍在 HEAD。 撤銷與否無法從程式碼查證。
修法:到各家 provider 主控台實際確認撤銷(不要採信 commit 訊息),然後把值從這九份 dump 清掉;把遮罩步驟加進對話歸檔 SOP。
面板:3/3 全票|位置:docs/features/FR-039-2606-distributed-file-agent/handoff/2026-06-18-FR039-handoff.md:89
一份受版控的交接文件寫明了拉取所有 jedi-* 套件用的 Nexus 私有 PyPI 帳密,同檔還嵌了共用 dev 租戶儲存設定的 MinIO secret key。該文件自己註明「憑證檔是 gitignored」——但被寫在文件裡的那組憑證本身就在 commit 裡。
這是供應鏈的根:有 Nexus 發布權就能推一個高版號、含後門的 jedi-common/jedi-iam,等下一次 poetry update 或 build_all.sh 在 build 機執行時載入。
修法:輪替 Nexus 帳號(且改用範圍受限的 deploy token 而非 admin)與 MinIO 金鑰;把字面值從 handoff markdown、docs/features-site/docs 副本、以及 docs/features-site/site/ 下產生的 HTML 一併清除。
面板:3/3 全票|位置:scripts/migrate_2026-08-09_fr062_existing_tenant_licenses.py:80
SEED_PASS = os.getenv("FR062_SEED_PASS", "<真密碼>")——環境變數沒設時,腳本會靜默拿真密碼去打真的 /login 端點。這種寫法同時毀掉兩件事:憑證外洩,以及「看起來像是外部化設定」的假象(操作者忘了設變數不會收到任何警告)。
修法:拿掉預設值,FR062_SEED_PASS 未設時直接失敗;把字面值從 docs/claude/memory/ 與各 feature handoff 清掉。
依決策者 2026-09-08 裁定,掃描結果產生的修正卡先不派,由 PM 收集所有掃描結果後統一安排。以下是當時的建議切法(現況:09-21 起改照 docs/security-report/SUMMARY.md 統一派工,F13/F16/F17/F18 與 F1/F2/F3/F12/F14 皆已修,F15 與 bulletin_org_units RLS 裁定不修,見上方一覽表):
| 建議卡 | 內容 | 理由 |
|---|---|---|
| 公告授權補強 | F13 + F16 + F17 + F18 + 重點 ① 的 bulletin.read 守門 |
四條全在 bulletin_service.py、改動相鄰,且共同的根因是「可見範圍判定散在各方法、且部分由呼叫端決定」。分開修會改到同一批行 |
| 憑證輪替與清除 | F1 + F2 + F3 + F12 + F14 + F15 | 全部是版控內憑證,處置動作同型(輪替 → 清字面值 → 加 pre-commit 掃描)。F12 與 F15 建議優先——前者是認證體系的根,後者影響每一套客戶安裝 |
bulletin_org_units RLS |
⑩ 的發現 | 獨立的 schema 修正,走 sql-migration SOP |
兩項未查的要另外安排:重點 ⑦(content 的儲存型 XSS,需開 FE repo 查渲染方式)與重點 ⑨(審計欄位 subquery 是否繞過 users 表 RLS)。這兩項本棒等於沒查,不能當作乾淨。
CLAUDE-SECURITY-20260908-134256/(含 .jsonl/.sarif/stamp,該目錄有自己的 .gitignore 不入版控)CLAUDE-SECURITY-REVISION-582cbdfda7b0-dirty.json,verification.status: verifiedscan-B1-package-core.md(CM-1611,套件本體)../FR-078-2609-notification-security-scan/scan-N2-host-wiring.md.claude/skills/security-scan-lead/SKILL.md