FR-079 · 需求索引 · 本頁由 build 掃資料夾生成

FR-079 公告(jedi-bulletin)資安掃描

✅ 兩棒皆掃完並驗收(2026-09-09);發現已全數處理——公告改刪驗歸屬、發送範圍過濾、儀表板旁路等已修(FR-114.1-5a/CM-2026、CM-2038,1.21.0 出貨);公告部門關聯表未開租戶隔離與原廠系統殼帳號裁定不修;版控殘留金鑰與密碼已清(CM-2048)

狀態:✅ 已完成 文件 2 份

這是六個資安掃描 arc 之一。 六個 arc 合起來的結論、修正卡狀態、還沒開卡的待辦與覆蓋率地圖,在跨 arc 總表:security-scan-consolidated/——要問「總共掃出什麼、還有哪些要修」看那份,本頁只講這一支。

🔴 一頁看完

這是第五個資安掃描 arc,目標是「公告」這條鏈。 兩棒都已掃完(B1 2026-09-08、B2 2026-09-09),結果見下方進度段。

§1

這在做什麼(白話)

公告是系統裡「一則訊息要發給哪些人看」的功能:管理員發公告、指定要發給哪些部門,其他人在自己的畫面上看到。這個 arc 要回答的是——有沒有人可以看到不該看的公告、改別人的公告、或發公告給不屬於自己的部門。

§2

為什麼掃它(這支被低估了)

全套件掃描排名把 jedi-bulletin 歸在「讀取展示型、攻擊面小」那組(29 檔、847 行),看起來風險最低。但那個評估只看了套件那半邊。

首腦這次讀完程式碼發現:套件本體確實只有沒有守門的 CRUD,而**「誰能看到哪一則公告」的判定整個在主專案這邊**,且判定邏輯出乎意料地繞——部門過濾的開關是呼叫端自己傳的、查詢條件用「物件欄位當白名單」動態組裝、單筆讀取完全不做範圍檢查。這是一條被低估的鏈。

§3

怎麼切(兩棒)

棒 卡 範圍 規模 scanRoot
B2 宿主接線 CM-1610 api/ app/ domain/ infra/ di_containers/ 五處 + dashboard 註冊檔 33 檔/899 行(實質 23 檔) BE repo
B1 套件本體 CM-1611 jedi_bulletin/ + harness/ 29 檔/847 行(實質 16 檔) jedi monorepo 的 jedi-bulletin/

分兩棒的唯一理由是工具的 scanRoot 只能指一個目錄,而這條鏈天然橫跨兩個 repo。檔數各自都在 30 檔尺內(2026-09-08 實測邊界),不是因為量才切的。與 FR-078(N1 套件 30 檔/N2 宿主 18 檔)完全同構。

建議先跑 B2——首腦標出的十個重點有八個落在那半邊。兩棒可平行(不同 repo、互不依賴),但額度考量下串行較穩。

§4

首腦標出的十個重點(全部未經驗證)

這些是讀過程式碼後認為最容易出事的地方,是給研究員的思考起點不是檢查清單。工具不會照這份清單走——FR-076 L2 的 HIGH 就是 runner 照這種清單自己追出來的。

宿主側(B2,八條)

  1. 兩條讀取 route 沒有 capability 守門——寫入三條(POST/PUT/DELETE)都掛了 @require_capability,列表與單筆讀取只有 @jwt_required()。與已修的 CM-1585/CM-1589 同形狀,是本專案已知會重犯的病。
  2. 單筆讀取完全不做範圍檢查——get_bulletin(uid) 只吃一個 uid,不看部門、不看建立者、不看 enable、不看發布與過期時間。列表那條費心做的部門過濾在這條路上完全繞過。
  3. 列表的部門過濾是反直覺的 if/else,開關由呼叫端自己傳——auth 是 request body 裡的布林值,不是伺服器判定的;else 分支對「沒有部門的使用者」註解明寫「不過濾、顯示全部公告」。
  4. 查詢條件是「拿物件欄位當白名單」的動態組裝——_gen_filters() 在 query_entity.__dict__ 上跑迴圈、用 hasattr(model, field) 決定要不要變成 SQL 條件,而 BulletinQueryEntity.__init__ 收 **kwargs、serializer 是 apply=False 手動 load()。呼叫端的鍵能不能變成查詢條件要實查。
  5. 寫入只驗 capability、不驗歸屬——update_bulletin 拿到 uid 直接改,delete_bulletin 連 user 參數都沒有。跨租戶最終只靠 RLS 擋,而 bulletins 表的 RLS 現況未經查證(migration 註解已明指其 INSERT policy 與其餘三支不對稱)。
  6. 更新時先刪光部門關聯再重建——沒帶 org_units 的部分更新會清空公告的可見範圍。CM-1588 同類,該案已確認為真 bug。
  7. content 是完全不驗證的 Raw 欄位——寫入讀出原樣進出,DB 是無長度上限的 Text。FE 渲染方式要實查再下結論。公告是「發給很多人看」的內容,XSS 受害面比一般欄位大。
  8. AI 儀表板的旁路入口——dashboard_apis/bulletin.py 把 get_bulletins 註冊給儀表板呼叫,不經過 route 層守門。

套件側(B1,兩條值得先講的)

  1. 模組層級的單例,import 時就實例化——bulletin_service.py 最後一行。CLAUDE.md 的「repo session 必須 lazy」鐵則相關。另有五個方法各自 new 一份 repo 繞過注入、update() 第 53 行檢查了參數而非查詢結果(查無此筆會 AttributeError)。
  2. generate_uuid 的實作要人工看一眼——公告 uid 若不是密碼學等級隨機,重點 ② 那條無守門的單筆讀取影響就放大。這是跨棒依賴:B1 的結論直接影響 B2 該條的嚴重度判定,B1 卡已要求「即使工具沒報也要人工看一眼並寫進報告」。
§5

🔴 對這個套件的特別警告

plugin.py 檔頭有 60 行極詳盡的自我辯護文字(自稱跨 jedi 套件 import 0 處、os.getenv 0 處、「本來就是乾淨的」)。這正是工具已知盲點「會被程式碼註解說服」的高危形狀——FR-075 的 S2 就是八個檔零發現,但 signed_token.py 的真洞被 docstring 寫成「刻意設計」,研究員讀到就接受了。

這個自陳是那一棒(P3.5 / CM-1471)的自我宣稱,不是驗證過的事實,正好要在本 arc 查證。驗收時首腦要確認 runner 有獨立結論,而不是複述檔頭。

§6

共同設定

沿用前四個 arc 的教訓,逐條都有代價:

  • 主 session 用 Opus 5 (1M context)——研究員繼承主 session 的模型與 context 上限;Sonnet 5(200K)會撞 workflow 的「180 秒無 tool call 判 stalled」被砍掉重派
  • effort low——單一研究員掃全範圍+三人面板;medium 會展開成矩陣派 40+ 個無共用 context 的 agent
  • focus attack-surface——跳過 tests/dist/docs,並附帶跑一次密鑰專項(FR-078 六條裡有四條是它掃出來的,免費收益)
  • 每棒 ≤30 檔——瓶頸是驗證面板不是研究階段(候選數×3 個 verifier、每個從零讀檔)
  • runner 不啟動掃描——/claude-security 帶 disable-model-invocation: true,指令交決策者親手打
§7

進度

棒 卡 狀態 報告 首腦驗收
B2 宿主接線 CM-1610 ✅ 掃完(2026-09-09) scan-B2-host-wiring.md ✅ 已驗(09-09);發現已處理(見 SUMMARY 總表 M17-1~11:已修 9、裁定不修 2)
B1 套件本體 CM-1611 ✅ 掃完(2026-09-08) scan-B1-package-core.md ✅ 已驗(09-08)

🔴 決策者裁決(2026-09-09):兩條範圍外發現的處置

F12(JWT 簽章金鑰散在版控 31 個檔)→ 降為衛生類,併入 CM-1607,DEV 金鑰不更換。

首腦驗收時原主張把它從 MEDIUM 升到 HIGH(實測與現行 .env 一致、已上 origin/main)。決策者問「這個值安裝時是不是動態產生?如果目前已經有,那就不用修」——這一問推翻了判級:scripts/installer/install.sh:1252 每套安裝各自跑 openssl rand -base64 32,外流的只是我們開發機那把,不是任何客戶的。影響從「所有客戶安裝」縮成「我們自己的 DEV」,與 CM-1608「自己的測試機」同判準。版控殘留字串仍要清,但性質是打掃不是堵漏。

F15(installer 每套部署種同一組原廠 super admin 密碼)→ 裁定不修(總表 M17-7)。 這是原廠系統殼帳號,密碼只有原廠知道、不交給客戶,用於原廠支援;客戶日常管理員由客戶在設定精靈自建自設密碼。與 F12 相反——JWT 金鑰動態產生了,這個沒有;本 arc 不開卡不派工。

教訓:判密鑰外洩的嚴重度,第一問是**「客戶端用的是不是同一把」**,不是「這把是不是活的」或「散在幾個檔」。沒查 installer 有沒有動態產生就談嚴重度,會系統性高估。已寫進 STATE 教訓段。

B2 結果摘要(2026-09-09)

十條發現全數 3/3 全票通過面板,verification.status: verified、unreviewed_candidate_sites: 0、38 票、候選 13 去重 11(另有 1 條 Turnstile 測試金鑰被 0:2 否決)。範圍內 4 條、密鑰專項範圍外 6 條,無 HIGH 以上。

範圍內四條(全在 app/bulletin/service/bulletin_service.py):

  • F13(MEDIUM)AI 儀表板旁路——dashboard_apis/bulletin.py 註冊的 get_bulletins 不經 route 層守門,且呼叫端不傳 auth 使部門過濾永遠不生效 → 任何登入者可讀全租戶公告(含草稿、過期),且前三筆原文送第三方 LLM。即母卡重點 ⑧+②,本棒最重要的發現
  • F16(MEDIUM)改/刪不驗歸屬——只有 @require_capability,delete_bulletin(uid) 連 user 參數都沒收;連帶 :165 無條件清空部門關聯(重點 ⑤+⑥)
  • F17(LOW)單筆讀取不做任何範圍檢查(重點 ②,依 B1 的 uuid4 結論由 MEDIUM 降級)
  • F18(LOW)列表對「無部門帳號」fail-open,且過濾開關由 request body 傳入(重點 ③)

範圍外六條:F12 JWT 簽章金鑰(34 處,與 .env 一致)、F15 installer 每套部署同一組原廠 super admin 密碼、F1 cmmgr(BYPASSRLS)密碼、F2 四家 LLM API 金鑰(九檔,5746cef1 只刪了 .env.test 沒動這些)、F3 Nexus+MinIO、F14 管理員密碼當 os.getenv 預設值。

🔴 重點 ① 有硬結論:這是漏守門不是設計如此。 bulletin.read(capability id 3)存在於 seed(04-seed-core.sql:218)、綁在 route 7 上且為 ALL 規則(:359)、系統設計文件明文寫了判定規則(generate_permission_system_docx.py:471-479)——但該字串在整個 Python 程式碼裡除文件產生腳本外一次都沒出現。與 CM-1585/CM-1589 同型。

🔴 重點 ⑩ 有母卡沒預期到的發現:bulletins RLS 齊全(四 policy,INSERT 的不對稱方向是更嚴格、屬設計);但 bulletin_org_units 整張表沒有 RLS、也沒有任何 policy。影響有限(只存兩個整數)但意味著「公告發給哪些部門」的關聯不受租戶隔離保護。

❌ 兩項本棒未觸及,不可當作乾淨:重點 ⑦(content 的 Raw 欄位儲存型 XSS,需開 FE repo 查渲染方式才能下結論)、重點 ⑨(審計欄位 subquery 是否繞過 users 表 RLS)。

過程教訓(值得記):前後撞兩次額度上限,第二次的 4 條被 dropped without a verdict(無下一輪可交棒),以 resumeFromRunId 續同一 run 補完 5 票。併檔時踩到工具邊界情況——同 shard 的兩份結果會靜默擇一保留,且 finding ID 互相衝突意義不同,差點把 3 條(含重點 ③⑤)吃掉。修法與判準寫在報告的「中斷與續跑」段。

B1 結果摘要(2026-09-08)

工具零發現——2 條候選(_gen_filters 空轉的過濾條件、harness 明文密碼)皆被三人面板 0/3 否決,verification.status: verified、6/6 票全數投出、42 分鐘跑完。這是本 arc 第二次拿到面板完整跑完的紀錄。

人工核對十個重點另找到三條(未經面板,可信度見報告):update() 檢查錯對象查無此筆會 AttributeError(LOW,bulletin_repo_impl.py:56,一個字的修正)、無 user context 時建立公告會崩潰而非寫入 NULL(LOW,bulletin_service.py:50/62)、update() 無歸屬檢查(INFO,屬 B2 範疇)。三條都因「套件版 service 沒有任何呼叫者」而目前打不到,故未開修正卡;反悔條件寫在報告末段。

🔴 重點 ⑧ 的答案(跨棒依賴,直接影響 B2):generate_uuid 用 uuid.uuid4(),122-bit 密碼學隨機、不可預測(common/utils/common_util.py:4-5)。B2 重點 ② 那條「單筆讀取不做範圍檢查」的嚴重度應據此下調但不歸零——列舉不可行,但 uid 會出現在列表回應與網址上,「事後仍能讀到已移出可見範圍的公告」仍成立。

plugin.py 檔頭 60 行自陳經獨立 grep 查證屬實(跨 jedi 套件 import 0 處、os.getenv 0 處)——是自己跑 grep 得到的結論,不是採信檔頭。

移交 B2 的線索:① 主專案自己那支 _gen_filters() 要獨立查是否有同樣的空轉問題(套件端空轉無害,主專案端空轉=時間窗過濾沒生效);② 主專案的 BulletinQueryEntity 收不收 <strong>kwargs 要實查(套件端不收,是 fail-closed);③ bulletins 表 RLS 四條 policy 已於 DEV 唯讀查證齊全,INSERT 那條的「不對稱」方向是更嚴格**、屬設計非缺陷。

§8

修正卡

掃描當時(2026-09-08)決策者裁定修正卡先不派、由 PM 統一安排;09-21 起改照內化版問題總表(docs/security-report/M17-bulletin.md)派工。結果:公告相關 4 條(改刪驗歸屬、儀表板旁路、直開網址、列表過濾)已修(FR-114.1-5a/CM-2026、CM-2038,1.21.0 出貨);關聯表未設租戶隔離與原廠系統殼帳號裁定不修;版控殘留金鑰、密碼、對話紀錄已清並換發(CM-2048)。

§9

座標

  • 首腦手冊:.claude/skills/security-scan-lead/SKILL.md
  • 四個 arc 共用現況:docs/features/FR-075-2609-jedi-package-security-audit/handoff/security-scan-STATE.md
  • 前案:FR-075(jedi-iam)/FR-076(License 鏈)/FR-077(遠端 Agent,暫停)/FR-078(通知投遞)
  • 報告格式範例:../FR-078-2609-notification-security-scan/scan-N2-host-wiring.md
  • branch:BE repo 與 jedi monorepo 都在 feature/FR-075,不要切
§10

文件

以下全部由 build 掃資料夾產生,新增檔案重 build 即自動出現。標題連結指向渲染後的 HTML,md 連向源檔。

證據與盤點

文件 類型 標題 最後更新
scan-B1-package-core / md 盤點證據 B1 掃描結果:jedi-bulletin 套件本體 2026-09-08
scan-B2-host-wiring / md 盤點證據 B2 掃描結果:公告鏈宿主接線(主專案) 2026-09-09
§11

Notion 卡

卡片內容(決策紀錄、驗收條件)以 Notion 為準,本頁只記座標。

關係 卡號 標題 狀態
母案 CM-1609 FR-079 jedi-bulletin 公告鏈資安掃描(兩棒,只掃不修) —
子卡 CM-1610 B2 宿主接線(33 檔,BE repo) 修正待驗證
子卡 CM-1611 B1 套件本體(29 檔,jedi monorepo) 修正待驗證