151c5f88c2b012e0368f0e47014d25300cbaf76a(branch feature/FR-075)claude-security plugin v0.11.0,effort low、focus attack-surfaceverified(三個獨立檢查員對 3 條發現各投一票,9 票全數投出,沒有漏投,三條都是三比零一致通過)掃完了,範圍內找到 3 個真問題,最嚴重的是「一張惡意流程圖能把伺服器卡死」——存進去之後,之後每個讀它的人都會把伺服器的一條工作執行緒佔住不放,多打幾次整個系統就不回應了。 好消息是:這一棒最擔心的 XML 老問題(讀走伺服器本機檔案、用小檔案撐爆記憶體)實測全部被擋住,不成立。
現況(以 docs/security-report/M06*.md 為準):F1(惡意流程圖卡死,M06-3)→ ✅ 已修(CM-2059);F2(套件庫走 http,原 CM-1634)→ ✅ 已修(版本鎖定檔入版控,連線維持 http 為決策者裁定);F3(空字串範本,M06-8)→ ✅ 已修(CM-2059);三支死碼方法(M06-14)→ 🗑️ 裁定刪除,已刪(1.21.0)。
流程圖(BPMN)是一種 XML 文字檔。使用者在畫面上畫好稽核流程存進系統,後端要「讀懂」這張圖,才知道這個階段做完之後下一棒是誰、要做什麼。
這一棒檢查的就是**「讀懂流程圖」那段程式**,問三件事:
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 |
|---|---|---|---|---|---|
| F1 🟡 MEDIUM |
程式沿著流程圖的「分岔點」往下走的時候,不會記得自己走過哪裡,也沒有設步數上限 | 一張「分岔點繞回自己」的流程圖會讓程式無限打轉 → 每次讀都 500。更糟的是「一路串接 40 個分岔點」的圖:程式會做 2 的 40 次方次計算、連錯誤都不會報,請求永遠不回來,一條工作執行緒就此卡死。多打幾次把執行緒吃光,整個系統對所有客戶都不回應 | ① 一個能存流程圖的帳號(module-frame.update 權限,或流程範本的新增/修改權限)② 之後有人讀到這張圖——不需要是攻擊者自己,正常使用者打開那一輪稽核就會中 |
jedi_flow_engine/common/utils/bpmn_uilts.py:361(_get_jobs_from_exclusive_gateway)與 :276 get_next_jobs 互相呼叫成環 |
在 get_next_jobs / _get_jobs_from_exclusive_gateway 之間傳一個「走過的節點清單」,走過就不再展開,並加步數上限。另外 :294 和 :295 把同一份清單算了兩次(_get_exclusive_gateway_default_job 在 :340 又算一遍),改成算一次傳進去,就能把「每過一個分岔點工作量翻倍」這件事直接消掉。存檔時也應該擋掉有環的流程圖 |
| F2 🟡 MEDIUM |
套件安裝來源用的是沒有加密的 http 連線,而且鎖定版本雜湊的 poetry.lock 沒有進版控 |
跟我們在同一個內網、且能夠插在中間的人,可以在我們安裝套件時把套件掉包,讓惡意程式在開發機和打包機上執行。打包機產出的正是要交給客戶的映像檔 | ① 攻擊者已經進到 192.168.50.0/24 這個內網,而且能做網路中間人(例如 ARP 欺騙) ② 有人跑 poetry install / poetry update |
pyproject.toml:72(priority = "primary" 在 :73,且是唯一來源) |
Nexus 改走 https;自簽憑證就把 CA 憑證發到各台機器,不要退回 http。另外把 poetry.lock 納入版控,讓雜湊比對成為第二道防線,不要只靠連線本身 |
| F3 🟡 MEDIUM |
每次從資料庫讀出流程範本,程式都會無條件去解析那段 XML,而且沒有防呆;寫入端的檢查又剛好會被「空字串」繞過 | 一旦有一筆範本的 XML 被寫成空字串,之後每一次讀到這筆資料的清單或詳細頁都會 500,而且不會自己好,要進資料庫修那筆資料才會恢復。受害的是其他正常使用者,不是寫壞它的人 | ① module-frame.update 權限② 送出時 xml 欄位留空或整個不帶③ 該語系的翻譯列已經存在 |
jedi_flow_engine/infra/mapper/workflow_template_mapper.py:21(解析動作在 bpmn_uilts.py:34)繞過點: workflow_template_repo_impl.py 的 if entity.xml: |
兩邊都補:① 寫入端驗 XML,空的或不是合法 BPMN 就回 400,不要用「有沒有值」當判斷;② to_entity 遇到空字串就跳過解析,解析失敗就退成「這張圖 0 個任務」,不要讓一筆壞資料拖垮整個查詢 |
這一棒沒有範圍外發現。 工具因為設了 focus 會額外跑一次「找密碼金鑰」的專項掃描(掃全 repo、不受 30 檔範圍限制),但這次沒有撈到任何東西——jedi-flow-engine 這個套件目錄裡沒有寫死的憑證。
這是什麼問題(白話)
流程圖裡有一種節點叫「分岔點」(exclusive gateway),意思是「看條件決定往左還是往右」。後端要算出「這一棒做完,下一棒是誰」,就得從目前位置沿著線往下走,碰到分岔點再往它的每條出線繼續走。
問題是這段走訪程式不會記得自己走過哪些節點。正常的流程圖從頭走到尾就結束了,所以平常沒事;但只要圖的形狀不正常,就會出事。
出事會怎樣
兩種形狀,兩種死法:
:294 和 :295 對同一個分岔點算了兩次——第一次直接算,第二次在算「預設走哪條」時又整份重算一遍。所以每多一個分岔點,工作量就翻一倍:40 個分岔點就是 2 的 40 次方次計算(一兆多次)。程式不會報錯,它只是永遠算不完,請求不回來,處理它的那條工作執行緒就此卡住。第二種特別難察覺:沒有錯誤訊息、log 裡什麼都不會有,從外面看只是「這頁一直轉圈」。多打幾次把工作執行緒吃光,整個後端就對所有客戶停止回應了。
要先有什麼才打得到
module-frame.update 權限(打 PUT /module-frame/item/xml/<uid>),或流程範本的新增/修改權限在哪裡
jedi_flow_engine/common/utils/bpmn_uilts.py:361(_get_jobs_from_exclusive_gateway 裡呼叫 self.get_next_jobs):276 get_next_jobs → :294 呼叫 _get_jobs_from_exclusive_gateway:295 呼叫 _get_exclusive_gateway_default_job,它在 :340 又把同一份清單重算一次首腦核對註記(我自己開檔看過)
屬實,而且有一個佐證讓我更確定這是「漏寫」不是「刻意設計」:同一個檔案裡另外兩支走訪流程圖的函式都有記路——get_job_execution_order(:392,visited = set() 在 :395)與另一支分層走訪(:454 起,visited 在 :470/:476/:485)。三支同類函式,兩支有防護、一支沒有,這是遺漏的典型形狀。
另外我確認了寫入端不會幫忙擋:WorkflowTemplateService.update_workflow_template_xml(workflow_template_service.py:158)只是把字串塞進 entity 就存檔,沒有做任何拓撲檢查。套件裡確實有一支拓撲驗證器(bpmn_topology_validator.py),但它是宿主的 flow-template 路徑才會呼叫(app/flow_engine/service/flow_template_app_service.py:143/:251),module-frame 那條寫入路徑完全繞過它;而且我讀了那支驗證器的檢查項目(起始階段、已知階段碼、後繼、結束階段、閘道條件、可達性),它沒有一項在檢查「圖裡有沒有環」。所以即使走 flow-template 那條路,這個洞一樣打得到。
怎麼修
主修法是給走訪加記路:在 get_next_jobs 與 _get_jobs_from_exclusive_gateway 之間傳遞一個已展開節點的集合,走過的分岔點不再展開,並設一個深度上限當保險。
順手要做的第二件事:把 :294 算好的 gw_jobs 直接傳給 _get_exclusive_gateway_default_job,不要讓它在 :340 重算——光這一項就能把「每過一個分岔點工作量翻倍」消掉,讓長串形狀從指數變成線性。
第三,存檔時擋掉有環的流程圖(拓撲驗證器加一項環偵測),並讓 module-frame 那條寫入路徑也走驗證。
這是什麼問題(白話)
我們的 Python 套件不是從公開的 PyPI 抓,而是從公司內網自架的 Nexus 抓。設定裡寫的網址是 http://——沒有加密。沒加密的意思是:跟我們在同一個內網、而且有辦法「站在中間」的人,可以在套件送過來的路上把內容換掉。
正常情況下還有第二道防線:poetry.lock 這個檔案會記下每個套件的指紋(sha256),裝的時候比對一下,被掉包就會發現。但這個 monorepo 把 poetry.lock 排除在版控之外,所以新 clone 下來的環境根本沒有指紋可比對,等於第二道防線沒跟著出貨。
出事會怎樣
攻擊者把 jedi-common(我們自己的核心套件,管資料庫連線和登入身分)換成帶惡意程式的版本。下次有人在開發機或 188 打包機上跑 poetry update,惡意程式就在那台機器上執行了。打包機產出的正是要交給客戶的映像檔,所以污染會一路流到客戶端。
要先有什麼才打得到
門檻不低——要先有內網立足點。但一旦成立,拿到的是打包機,等級很高。
在哪裡
pyproject.toml:72,url = "http://192.168.50.171:8082/repository/pypi-group/simple",下一行 :73 標著 priority = "primary"。因為 Poetry 一旦設了 primary 來源就會把公開 PyPI 停用,所以所有依賴都走這條明文連線。
首腦核對註記(我自己開檔看過)
行號與內容逐字屬實。這條嚴重度是 MEDIUM 而不是 HIGH,理由是「要先進內網」這個前提相當實質——它不是一個外部人能直接打的洞。但它也不是純理論:內網本來就會被當成「已經半信任」的區域,而這條剛好落在「開發機與打包機」這種高價值目標上。
怎麼修
poetry.lock 納入版控。這樣即使連線被動手腳,指紋比對也會擋下來——兩道防線不要只剩一道這是什麼問題(白話)
每次從資料庫讀出一筆流程範本,程式都會順手解析那段 XML,好算出「這張圖有幾個任務」。問題是這段解析沒有任何防呆——XML 壞掉就直接拋例外,整個查詢跟著失敗。
而寫入端的檢查剛好有個縫:它用的判斷是「這個欄位有沒有值」(if entity.xml:),而空字串在 Python 裡算「沒有值」,所以送一個空的 XML 進去,反而會跳過驗證直接存下來。
出事會怎樣
一旦某筆範本的 XML 變成空字串,之後每一次讀到這筆資料的清單頁或詳細頁都會 500。而且它不會自己恢復——要有人進資料庫把那筆資料修掉才會好。受害的是其他正常使用者(他們只是想看流程範本清單),不是寫壞它的那個人。
要先有什麼才打得到
module-frame.update 權限PUT /module-frame/item/xml/<uid> 時,xml 欄位留空或整個不帶(例如 body 直接送 {})在哪裡
jedi_flow_engine/infra/mapper/workflow_template_mapper.py:21,bpmn_obj = BpmnUtils(workflow_template.xml),沒有 try/exceptjedi_flow_engine/common/utils/bpmn_uilts.py:34jedi_flow_engine/infra/repository/workflow_template_repo_impl.py 的 if entity.xml:api/module_frame/routes/module_frame_item_route.py:100,payload.get("xml", ""),沒有掛任何欄位驗證 schema首腦核對註記(我自己開檔看過)
程式碼的形狀屬實:to_entity 確實是所有查詢(依 uid、依 id 清單、依欄位全查)的唯一轉換點,確實沒有 try/except;進入點也確實沒有 schema 驗證。我也實測確認了空字串會讓解析器拋錯:xmltodict.parse("") 回 ExpatError: no element found," " 同樣。
可信度標低的原因:整條路徑是讀程式碼推出來的,我沒有真的送那個請求去驗(本棒紀律是只掃不修、不動環境)。中間有一段我只能從程式碼判斷——寫入時 to_entity 會先被呼叫一次,此時若翻譯列已存在,它拿到的是舊的主表 XML(解析得動),交易才 commit 得成功;若這個前提不成立,寫入當下就會炸,那這條就只是「送出者自己收到 500」,不構成對別人的影響。這個前提沒有實測過,是這條可信度低的唯一原因,不是面板有人反對(三票一致通過)。
怎麼修
兩邊都要補,缺一不可:
module_frame_item_route.py 加欄位驗證,xml 空的或不是合法 BPMN 就回 400。repo 層的 if entity.xml: 也改成明確驗證,不要用「有沒有值」當判斷to_entity 遇到空字串直接跳過解析;解析失敗就 try/except 退成 job_count=0 / jobs=[]。一筆壞資料不該讓整個查詢失敗卡片列了 8 項思考起點,逐項交代。其中 5 項證偽——不成立也是結果,而且知道「什麼擋住了」比知道「沒事」更有用。
| # | 卡片上的疑點 | 結論 | 是什麼擋住了/為什麼成立 |
|---|---|---|---|
| 1 | 🔴 解析器沒有大小與深度上限 | 不成立(部分成立,但打不動) | 三件事分開講:① 外部實體(讀本機檔案)已擋——我實測送 <!ENTITY x SYSTEM "file:///etc/passwd">,回來的是 {'r': None},檔案內容沒進來,因為 xmltodict 0.14.2 的 parse() 預設就帶 disable_entities=True,且四處 parse 沒有一處把它關掉。② 實體炸彈(小檔案撐爆記憶體)已擋——同一個預設值;我實測三層巢狀實體展開後長度只有 11 個字元,沒有展開。③ 深度巢狀不會炸——我實測 50000 層巢狀,解析器正常回傳、沒有 RecursionError,因為 expat 是迭代式的不是遞迴式的。超大文件的記憶體壓力受宿主 50MB body 上限保護,屬於既有的 CM-1587 範疇 |
| 2 | 產生器用兩個解析器,有沒有 ET.fromstring 漏網 |
不成立 | 我 grep 過 bpmn_generator.py、bpmn_uilts.py、bpmn_topology_validator.py、common_utils.py 四個檔的 ET.fromstring / ET.parse / ElementTree.parse / XMLParser,零命中。ET 的用途全部是產生端(Element / SubElement / tostring / register_namespace / indent / ElementTree() / write),三處解析全走 safe_parse(defusedxml)::1541、:1563、:1982。檔頭自陳與實況相符 |
| 3 | 🔴 三支吃檔案路徑的方法是死碼還是殘留 | 成立(是死碼),但目前無法觸發 | save_bpmn_to_file(:963)、export_xml(:1220)、import_xml(:1241)確實用 open(file_path) 直接讀寫。我在 jedi monorepo 全域與 BE repo 全域各 grep 一次呼叫者,兩邊都是零。所以現在不是洞——但它是一個裝好的陷阱:只要日後有人把使用者送來的字串接上去,就是任意檔案讀寫。建議刪除,或移進測試專用模組。這條沒有獨立開卡的價值,併進 F1 的修正卡順手處理即可 |
| 4 | 條件參數是拼字串比對,不是 eval | 確認安全 | 我對整個 jedi_flow_engine/ 目錄 grep eval( / exec( / pickle / os.system / subprocess,全部零命中。_parse_condition_param_to_string(:370)只是把 dict 拼成 ${key == 'value'} 字串,然後與流程圖裡的條件做完全比對,沒有任何動態求值。至於「key/value 含特殊字元造成錯配到預設分支」——這個可能發生(字串比對對 typo 和型別不合都很脆弱),但後果是「流程走錯支線」屬於正確性問題不是資安問題,而且程式碼在 :311 已經留了 warning log 會把錯配記下來 |
| 5 | 圖有環時會不會無限遞迴 | ✅ 成立 → F1 | 見上方 F1。而且比卡片預期的更糟:除了「繞圈無限遞迴」,還有「長串指數爆炸」這個不報錯的變體,後者更難察覺 |
| 6 | 範本讀取無租戶檢查、資料庫隔離關閉 | 屬實,但責任在宿主不在套件(見下節專段) | 見第 6 節 |
| 7 | 建立範本時不解析,惡意 XML 之後每次讀取才炸 | ✅ 成立 → 正是 F1 與 F3 的共同放大器 | 我核對過 update_workflow_template_xml(workflow_template_service.py:158):確實只把字串塞進 entity 就存,沒有拓撲檢查。這就是為什麼 F1 和 F3 的影響面這麼大——壞東西存進去是一次性動作,但之後每一個讀它的人都會中,包含完全無辜的其他使用者 |
| 8 | __main__ 區塊讀本機檔、三個 .bpmn 範例檔有沒有內網位址或憑證 |
**已消失(掃描期間被另一棒刪掉) | 掃描當下這些還在,但掃描進行中有平行 session 對這個套件 commit**(見第 7 節):__main__ 區塊(原 :715-720)連同三個 .bpmn 範例檔一起被刪了。我在 HEAD 確認:jedi_flow_engine/common/utils/ 底下現在只剩四支 .py,範例圖不存在了,所以「範例檔裡有沒有內網位址」這題在 HEAD 上已經沒有對象 |
卡尾追加的疑點是:流程定義 GET 把整份範本 XML 回給任何登入者,沒有分客戶。 本棒要答兩題。
答:不該,但套件也沒有提供讓宿主擋的手段,這才是套件側的缺口。
租戶隔離的正確位置有兩層,套件側的責任是**第二層(資料庫)**不是第一層(守門):
我實查了 DEV 資料庫(唯讀 SELECT):
workflow_templates | rls=false | force=false
workflow_templates_trans | rls=false | force=false
四條隔離規則(select/insert/update/delete)都好好地寫在那裡,但總開關沒有打開,所以四條規則一條都不生效。這正是 FR-087 T1 第 2 項已經記錄過的狀態,本棒獨立複查確認屬實。
套件側 model(infra/models/workflow_template.py:11)有掛 TenantScopedMixinModel,所以寫入時會自動填 tenant_id——資料是有租戶歸屬的。我查了資料量:18267 筆範本,其中 18076 筆有 tenant_id,191 筆沒有(那 191 筆正是 FR-087 記錄的凍結副本,宿主 snapshot service 建的,屬 H3 範圍)。
也就是說:資料齊了、規則寫好了、就差把開關打開。 而 repository 這一側(workflow_template_repo_impl.py)全靠 get_one_by_fields / get_all_by_fields 這種泛用查詢,Python 這一層完全沒有租戶條件——這是對的做法(專案慣例就是租戶隔離交給資料庫 RLS,Python 不重複過濾),但前提是那個開關要開。開關關著的時候,這個設計等於沒有任何隔離。
結論:套件不該自己做「他是不是這個專案的人」的守門,但套件應該確保它提供的租戶隔離是實際生效的。目前它提供了一個看起來有、實際沒開的隔離機制——這比沒有更危險,因為宿主會以為有。
我在 BE repo grep 了 get_workflow_template_by_uid 的所有呼叫點,共 18 處,其中:
api/flow_engine/routes/flow_engine_route.py:36,也就是錨點指的那支。這支只掛 @jwt_required()(:32),沒有任何專案歸屬檢查,拿到 uid 就回整份 DTO 含完整 XML。所以直接暴露的路是一條,但 get_workflow_template_by_uid 這支方法本身在宿主裡被大量複用,任何一個沒守門的入口都會再開一條路。這一點屬 H2/H3 的範圍,本棒只負責把套件側的事實釐清。
建議:這條不要開新卡,併進 H2 既有的守門修正卡(錨點來源就是 H2),另外**「打開 workflow_templates 的資料庫隔離開關」要獨立開一張卡**——它跨 H2/H3/本棒,且修法單純(一道 ALTER TABLE ... ENABLE ROW LEVEL SECURITY migration),但必須先確認那 191 筆無租戶歸屬的凍結副本開了之後會不會變成誰都讀不到,否則會打壞既有功能。
分兩層說,這兩層的答案不一樣,不要混在一起看。
三條發現我都自己開檔核對過,行號逐一對上(而且是對 HEAD 核對的,不是對掃描當下的版本)。F1 額外有「同檔另外兩支同類函式都有記路」這個佐證;F3 的解析器行為我實際跑過確認;第 5 節那五項證偽也都是實測出來的,不是讀程式碼推的。
面板狀態:三個獨立檢查員對 3 條發現各投一票,9 票全數投出,沒有漏投,三條都是三比零一致通過,沒有任何一條的嚴重度被調降。驗證章 verified。
唯一的例外是 F3 的可信度標「低」——原因寫在該條的核對註記裡:整條路徑是讀程式碼推出來的,中間有一個前提(寫入當下翻譯列已存在、交易得以 commit)我沒有實測。這是研究員與我自己對證據強度的誠實標示,不是面板有人反對。
面板完整跑完,這一點和 FR-077 R1 那種「面板全滅只能撈檔案」的情況不同,所以這個範圍可以宣稱掃過了。
但有兩個保留要說清楚:
coverage.research 這次是空的,所以我無法證明 30 支檔真的每一支都被讀完。可以說的是「範圍完整交給了研究員」,不能說「每一支都被讀到底」。掃描期間的版本漂移:掃描從 151c5f88 開始跑,跑到一半有平行 session 對 jedi monorepo commit,HEAD 移到了 45f14ad9。差異我逐行看過,是註解改寫與死碼刪除,沒有行為變更:bpmn_uilts.py 改了一個 logger 名稱、把三段施工日誌型註解換成精簡版、刪掉檔尾的 __main__ 除錯區塊;另外刪了三個 .bpmn 範例檔,infra/models/__init__.py 與 common/enum/error_code.py 有小幅調整。三條發現的程式碼邏輯都沒被碰到,本報告引用的行號全部是 HEAD (45f14ad9) 的行號。驗證章上記的仍是掃描實際跑的版本 151c5f88(標 dirty,因為當時工作區有未提交變更)。
| 項目 | 數字 |
|---|---|
| 掃描版本 | 151c5f88c2b012e0368f0e47014d25300cbaf76a(dirty;HEAD 期間移至 45f14ad9) |
| 範圍檔數 | 30(27 支 .py + 3 支 .bpmn 範例圖;git ls-files 原始輸出 31,多的一支是 .DS_Store) |
| effort / focus | low / attack-surface |
| 研究員 派出/回報 | 2 / 2 |
| 候選發現 | 4(去重後 3) |
| 面板票數 | 9(3 條 × 3 個檢查員),全數投出 |
| 達到共識的發現 | 3(皆 3:0 一致通過) |
| 未經檢查的候選點 | 0 |
| 嚴重度被調降的發現 | 0 |
| 交給下一輪驗證的候選 | 0 |
| 中途遺失的候選 | 0 |
| 驗證輪數 | 1 |
驗證章 status |
verified |
| 總耗時 | 約 7 小時 15 分(agent 12 個,11 個完成、1 個出錯) |
| 產物位置 | jedi-flow-engine/CLAUDE-SECURITY-20260912-120138/(.gitignore 內,不進版控) |
| run ID | wf_c14982f8-67a |
| 發現 | 建議 | 理由 |
|---|---|---|
| F1 | 開修正卡,優先序高於 F2/F3 | 這是三條裡唯一「外部可觸發、後果是全系統不回應、且無辜使用者會替攻擊者觸發」的。修法明確(加記路 + 消掉重算),範圍限縮在單一檔案的三支函式。第 5 節第 3 項的三支死碼方法建議併進同一張卡順手刪掉 |
| F2 | 開卡,但屬基礎建設不綁出貨 | 要先進內網才打得到,且修法(Nexus 上 https + lock 進版控)牽涉部署與團隊流程,不是單一 repo 的程式修改。注意這條不只影響 jedi-flow-engine,整個 monorepo 與主專案都是同一組設定,應該當成跨 repo 的一張基礎建設卡 |
| F3 | 開卡,可與 F1 合併 | 同一個套件、同一類根因(不信任存進來的 XML)、修改的檔案不同但相鄰。合併成一張「BPMN 輸入強固化」卡比拆兩張省事,但寫入端的修改會動到宿主 route,要注意跨 repo 派工衝突 |
| 資料庫隔離開關 | 獨立開卡,不要併進上面任何一張 | 跨 H2/H3/本棒,修法單純但有風險——要先確認 191 筆無租戶歸屬的凍結副本在開關打開後不會變成誰都讀不到。這是 migration + 資料修補的組合,性質與上面三條都不同 |
| 流程定義 GET 守門 | 併進 H2 既有守門卡,不開新卡 | 錨點來源就是 H2,責任層在宿主 route 不在套件 |