檢查日期 2026-09-16|對應卡片 CM-1826|檢查範圍 31 個檔案
找到 4 條發現、0 條高風險,全部中等。 其中 2 條(誰都能改刪別人的回饋、誰都讀得到全部回饋)是 CM-1630 搬家後的新落點,位置更新、不另計;1 條(GitHub 連線關掉憑證驗證)是 CM-1632 的重複,位置更新、不另計;淨新增只有 1 條——匯出檔的 Excel 公式注入。另有 1 條人工查出、工具沒報的新發現:任何登入者都拿得到全站使用者名冊(成員清單端點)。
最該優先處理的是 CM-1630:使用者的意見回饋,任何一個登入的人都能改掉或刪掉,刪除還會連附件一起實際刪除。
現況(以 docs/security-report/M21-issue-rescan.md/M23-issue.md 為準):F1/F2 → ✅ 已修(FR-114.1-7/CM-2030,1.21.0 出貨;原卡 CM-1630 作廢);GitHub 憑證驗證 → ✅ 已修(CM-2066);匯出公式注入 → ✅ 已修(CM-2055);成員名單端點 → 🗑️ 死程式已刪除(FR-114.3-4)。
使用者在產品裡送意見回饋、改回饋、刪回饋、刪附件、匯出、看標籤選單、查成員名單——這七個對外功能,FR-099 之後全部搬進了 jedi-issue 套件。這一棒看的是:誰能做哪件事、能不能動別人的、內容流到 GitLab/GitHub 時帶了什麼、守門是怎麼從主專案注入進來的。
掃描目標 /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-issue,revision 4c208d401c01(branch feature/review),mode scan,scope 31 個檔案(api/ 整層 10 支、app/feedback/ 6 支、domain/feedback/ 8 支、infra/feedback/ 7 支),effort low。檔數與卡片列的 31 條路徑逐檔比對完全一致。
這是一次限定範圍的掃描。套件內其他區塊——plugin/(屬 J2)、common/、標籤三層(屬 J2)、infra/issue/、infra/member/、migrations/(屬 J2 或 J3)——不在這次範圍內,未被當作稽核目標。
low 強度:一位研究員讀完 31 個檔案就提報候選,未做元件盤點、未做威脅建模、未跑額外密鑰專項掃描,completenessCheckOutcome 為 not-applicable(低強度本就不跑盤點)。驗證跑了 1 輪,4 個候選去重後仍是 4 個,沒有候選遺失、沒有候選被駁回。F1 的嚴重度被面板從 HIGH 一致下修為 MEDIUM。
有一條發現(F3)的位置落在範圍外的檔案 jedi_issue/infra/issue/adapter/github/github_issue_adapter.py(屬 J3 範圍)。那是研究員從範圍內的資料流(送出回饋 → 建 GitHub issue)追出去讀到的,不是把範圍擴大去稽核;標「⚠️ 越界(屬 J3)」,留下來是因為範圍內的功能會實際走到那行。
工具沒有實際執行任何程式碼:沒跑測試、沒發請求、沒示範攻擊,四條發現全部是讀原始碼推出來的。
工具碰到卡片列的七個重點裡的三個(①③的一部分、④)。 ②⑤⑥⑦ 與 ①③ 的其餘部分,由首腦回頭開檔並連 DEV DB 唯讀查證補上,詳見下方「卡片重點逐項人工查證」段。
這是什麼問題。 使用者送出的意見回饋,後端在「要改」「要刪」的時候完全沒檢查「這筆是不是你建的」。前端雖然有判斷(不是你建的就不顯示按鈕),但那只是把按鈕藏起來,直接呼叫 API 就繞過去了。
出事會怎樣。 同一家客戶裡任何一個最低權限的登入使用者,可以竄改或刪除全部意見回饋紀錄——包含別人回報的問題內容、標籤與附件。刪除不只是隱藏:會連帶把本地 issue 關掉、把附件檔實際刪掉,畫面上救不回來。
要先有什麼才打得到。
在哪裡。
jedi_issue/api/routes/feedback_route.py:92-95(FeedbackRoute.delete)、:75-87(FeedbackRoute.put)、:103-108(FeedbackFileRoute.delete)——三支都只掛 @auth_required(只驗有沒有登入)jedi_issue/app/feedback/service/feedback_service.py:241-243(update_feedback)、:278-280(delete_feedback)、:304-306(delete_feedback_file)——三支都是 get_feedback_issue_by_uid(uid) 撈出來就直接動,user_login_name 只被拿去寫 updated_user 欄位與關單紀錄,沒有拿去做任何判斷compliance-manager-fe/src/views/feedback/system/SystemFeedbackManage.vue:165-175 的 canEdit() 明確比對 item.created_user === userInfo.value.login_name——擁有者概念只活在前端怎麼修。 在 FeedbackService.update_feedback() / delete_feedback() / delete_feedback_file() 內,取到 feedback_issue 之後比對 created_user 與當前登入帳號,不符且非管理員時丟 ForbiddenError。管理員旁路走宿主注入的權限點判定(feedback.update / feedback.delete,DEV 資料庫已有這兩顆),不要只靠前端。
驗證。 3/3 三位檢查員(可達性/影響/既有防禦)一致確認成立。研究員原評 HIGH,三位一致評 MEDIUM,嚴重度已依面板結果下修——理由是要先有這家客戶的帳號、且跨客戶被資料庫隔離擋住。
首腦核對註記與判定。 屬實,逐檔開過,前端那側也開檔核對過。這是 CM-1630 搬家後的新落點,標「重複 CM-1630,位置更新」,不另計。 比原卡多出來的細節:delete_feedback_file 這支當年 CM-1633 另外開過卡,現在確認它與改/刪走同一個缺口(都沒驗擁有者),修法應一次三支一起。
這是什麼問題。 「查看意見回饋」這件事,資料庫裡明明有權限點(feedback.read、feedback-view.read),但後端的清單與明細端點根本不檢查它,只看有沒有登入。
出事會怎樣。 沒被授予任何回饋權限的一般使用者(甚至只該看自己那筆的人),可以讀到全租戶所有人回報的問題內容。回饋內文常帶操作截圖說明、系統錯誤訊息、內部流程細節,屬資訊外洩。跨客戶仍被資料庫隔離擋住,所以是同一家客戶內部的外洩。
要先有什麼才打得到。
mount_api=True,Guidant AI 預設如此,見 core/plugins/issue.py:155)在哪裡。 jedi_issue/api/routes/feedback_route.py:35-46(FeedbacksRoute.post,清單)與 :51-56(FeedbackRoute.get,明細)只掛 @auth_required。對照同檔 :113-118 的匯出端點多掛了 @capability_required(lambda rt: rt.config.feedback_export_capability)——形狀對照明顯。回出去的欄位見 jedi_issue/api/serializers/feedback.py:23-38:標題、描述、附件清單、建立者帳號與暱稱全在內。
怎麼修。 比照匯出那條,在清單與明細端點補上 @capability_required(...)(讀取用 feedback.read 或 feedback-view.read),並讓「只能看自己那筆」的使用者走一條以 created_user 過濾的路徑。權限點名沿用 jedi_issue/plugin/contract.py 的 CAPABILITIES 清單內既有字串,不要另創。
驗證。 3/3 一致確認成立,嚴重度 MEDIUM。
首腦核對註記與判定。 屬實。同屬 CM-1630 的範圍(該卡原文是「意見回饋六操作只有匯出守權限」),標「重複 CM-1630,位置更新」,不另計。 另查 DEV 資料庫確認:feedback.read、feedback-view.read、feedback.create、feedback.update、feedback.delete、feedback.export 六顆權限點都已建立,但只有 feedback.export 在後端被實際檢查——其餘五顆目前只有前端在用來藏選單。這與 FR-098 第 80 項(設備/資訊系統讀取端點不驗權限)是同一種產品決策題,套件 feedback_route.py:3-4 檔頭也自陳「要加是產品決策」。要不要守,建議與第 80 項一次裁。
這是什麼問題。 後端連到 github.com 時,程式碼寫死「不要驗對方的憑證」。等於打電話報密碼前不確認接電話的是不是本人。
出事會怎樣。 能插進後端與 github.com 之間網路路徑的攻擊者,可以用假憑證攔截連線,取得 GitHub 存取權杖(該權杖對設定的專案有問題單讀寫權),並竄改往返的內容。憑證外洩的影響跨出本系統邊界——拿到權杖就能直接操作那個 GitHub 專案。
要先有什麼才打得到。
在哪裡。 jedi_issue/infra/issue/adapter/github/github_issue_adapter.py:40(GitHubIssueAdapter.init_config)的 Github(auth=auth, ..., verify=False);jedi_issue/infra/github.py:22 的 fallback 版本同病。回饋建立/更新流程(FeedbackService.add_feedback → FeedbackIssueService.create_github_issue)會實際走到。
怎麼修。 移除 verify=False(PyGithub 預設即驗證)。若目標是內網自架 GitHub Enterprise 需要私有憑證機構,改成把憑證路徑傳給 verify=,不要整個關掉;兩處一起修。
驗證。 3/3 一致確認成立。
首腦核對註記與判定。 屬實。這是 CM-1632 的重複(原卡即記「verify=False 兩處」),標「重複 CM-1632,位置更新」,不另計。 兩處位置與 FR-101 README 開卡時記的完全一致,確認搬家沒修。這兩支檔屬 J3 範圍,研究員為追資料流讀到,本報告不把它們計入 J1 的覆蓋率。
這是什麼問題。 使用者在回饋標題裡填的字,匯出成 Excel 檔時直接照抄進儲存格。如果那串字是以等號開頭,Excel 會把它當成公式執行,而不是當成文字顯示。
出事會怎樣。 管理員下載匯出檔用 Excel 或 LibreOffice 打開時,攻擊者植入的公式會被當成公式處理;配合特定手法可以把同一份檔案裡其他儲存格的內容偷偷傳到外部網址,或在管理員按下安全性提示的「啟用」後,於該台工作站執行外部程式。
要先有什麼才打得到。
feedback.export 權限的管理員,且把匯出檔用試算表軟體打開在哪裡。 jedi_issue/app/feedback/service/feedback_service.py:389(FeedbackService.export_feedbacks 的 Excel 分支 ws.append(...)),CSV 分支在同函式 :378(writer.writerow(row))。組進去的欄位有標題、問題描述、標籤名稱、建立者暱稱,全部來自使用者填的內容。openpyxl 對開頭是 = 的字串會把儲存格型別設成公式。
怎麼修。 匯出前對每個字串欄位做中和:開頭為 =、+、-、@、Tab、換行時前置單引號(或明確把儲存格型別鎖成字串)。中和函式放在 export_feedbacks() 組資料的地方,Excel 與 CSV 兩種格式共用一份。
驗證。 3/3 一致確認成立,嚴重度 MEDIUM,confidence medium。confidence 不是 high 的原因:利用鏈需要受害者主動用試算表軟體開檔並點過安全性提示,這一步無法從程式碼確認一定會發生。
首腦核對註記與判定。 屬實,本棒唯一淨新增。 開檔核對:export_rows 六個欄位裡,功能(標籤名)、標題、建立使用者(暱稱)、問題描述 四個都來自使用者可控內容;問題描述 那欄雖然過了 html_to_text(),但那只是把 HTML 標籤拔掉、不會處理開頭的等號。與 FR-097 第 78 項(另一處匯出的公式注入)是同一種病、不同位置,建議兩處一起修,或抽一支共用的中和函式。
低強度單研究員掃描提報 4 個候選,去重後仍 4 個;三位檢查員各自獨立對四個候選投票,共 12 票全投出、沒有漏投。四條全部 3:0 通過,沒有候選被駁回。F1 的嚴重度被面板一致從 HIGH 降為 MEDIUM。票數由工作流程的程式碼統計,不是人工填寫。驗證章狀態 verified,無拒收理由。
🔴 低強度只跑一位研究員,卡片列的七個重點只碰到三個。逐項回頭人工核對如下。
七條 route 的守門現況逐條核對:/issue/get_members(只登入)、/feedbacks 清單(只登入)、/feedback 建立(只登入)、/feedback/<uid> 讀/改/刪(只登入)、/feedback/file/<uid>/<file_uid> 刪附件(只登入)、/labels/menu/<scope>(只登入)、/feedback/export/<type>(唯一有權限點)。與卡片描述完全一致。
「有沒有任何一層比對 created_user」→ 沒有。 app 層三支方法(update_feedback / delete_feedback / delete_feedback_file)全部只用 uid 定位,user_login_name 只寫審計欄位。domain 與 infra 層更不可能有(它們拿不到「誰在操作」)。
「任何登入者是否看得到全租戶所有人的回饋」→ 是。 get_feedbacks_and_pager(:151)不帶任何身分條件,送空 filters 就回本租戶整表(分頁內)。
🔴 卡片問的「跨租戶 join」那半——工具完全沒碰,人工查證結果是:風險成立但比卡片假設的更廣。 DEV 唯讀實查(2026-09-16):feedback_issues 開了隔離(4 條規則、不強制),但 _attach_issues(:131)撈 issue 內容走的是 feedback_issue_service.get_issues() → IssueProviderCode.LOCAL → issues 表,而該表隔離是關的(relrowsecurity=f、0 條規則)。同理 labels、三張 mapping 表也全是關的。
但實際影響被一件事限縮:_attach_issues 是「先撈本租戶的 feedback_issues,再用它的 issue_uid 去比對」,所以清單頁不會憑空多出別租戶的回饋。真正的缺口是方向相反的——issues 表零隔離代表任何走到那張表的路徑都沒有租戶邊界,而 feedback_issues 的隔離等於只鎖了門、牆是空的。這與跨 arc 總表 §3.1 第 7 項(六張表零隔離)是同一根因,歸第 7 項,不另計。
api/guards.py:28-64 三支:auth_required 與 capability_required 都是把判斷委派給宿主注入的實作(lazy,在 request 期才解),current_user_login_name 缺了回 None。current_user_login_name 為 None 時 add_feedback 的 created_user 是空,之後誰都算不上主人」——查證結果:這個假設的前提在 Guidant AI 不成立。 plugin/assembly.py:88-105 的 _assert_api_wiring 在掛 route 前檢查 auth_required/capability_required 兩項,缺了直接 RuntimeError 拒絕掛載;而 current_user_login_name 不在必檢清單內——這是真的。但主專案 core/plugins/_host.py:208-210 的 host_defaults() 三樣都給齊了,core/plugins/issue.py:155 用 <strong>host_defaults() 全收。所以在本產品內它永遠有值。 風險是「別的宿主接錯」的假設情境,不是現況缺口;且就算真的是 None,created_user 空只會讓那筆回饋沒有主人,不會讓別人變成主人——在 F1 已經沒人驗擁有者的現況下,這件事沒有額外影響。不成立,不計。**auth_required 給的是 jwt_required() decorator 本身(不是 lambda)——查證結果:兩種寫法在此處等價。 套件的 auth_required 每次呼叫都是 guard(fn)(*args, <strong>kwargs),jwt_required() 回的那顆 decorator 是無狀態的、可重複套用,每個 request 重新包一次也不會殘留狀態。_host.py:197 的註解自己也標明了這個差異。不成立,不計。**_guard 有沒有漏 method——逐條核對 routing.py:24-29 六個 resource 對應的所有 HTTP 方法,七條端點全部都有掛 @auth_required,沒有裸奔的。request.files['json'] 不存在時炸什麼(feedback_route.py:62/:77):Flask 的 request.files 是 MultiDict,['json'] 找不到會丟 BadRequestKeyError,那是 HTTPException 的子類、映到 HTTP 400,不是 500。jedi_common 的錯誤處理器對 HTTPException 保留原生狀態碼。行為可接受,不是缺口(送壞的請求拿到 400 是對的),但錯誤訊息對前端不友善,屬體驗問題不屬資安。issues 與送到 GitHub 時有沒有清:沒有清。 add_feedback 收到的 content 原樣往下傳,html_to_text() 只在匯出時用(feedback_service.py:364)。但這不構成本系統的漏洞:前端顯示回饋內容時如何渲染,決定了會不會變成 XSS——查過 SystemFeedbackManage.vue 沒有 v-html。送到 GitHub 那側是外部系統的渲染政策,GitHub 自己會處理 Markdown 的安全性。不計,但若前端未來改用 v-html 顯示,這條會立刻變成儲存型 XSS,值得記一筆。create_issue 直接把 labels 往下傳),GitLab/GitHub 走名字(feedback_issue_service.py:169 的 self._label_names(labels) 轉一次)。塞不存在的 uid 會在 mapping 寫入時失敗或被忽略,不會造成越權。不計。MAX_CONTENT_LENGTH 擋(core/app_factory.py:93,主專案設定),屬宿主層、本套件不管。檔名處理在 infra/issue_upload_files/(屬 J3 範圍),本棒未查,留給 J3。export_type 有沒有白名單——有。 feedback_service.py:370/385 是 if export_type == 'csv' / elif export_type == 'excel' / else: raise BadRequestError,只有兩個值會過。URL 上塞任何其他字串都回 400。不成立,不計。export_rows[0].keys()(:373/:387)在一筆回饋都沒有時會 IndexError→500。屬穩定性不屬資安,順手記一筆。api/routes/issue_member_route.py:11-15 只有 @auth_required,直接回 member_service.get_members() 的結果、不包回應信封(檔頭註明那是既有對外契約)。members 表(app/member/service/member_service.py:13-21 → domain → infra/member/),DEV 唯讀實查:該表隔離是關的(relrowsecurity=f、0 條規則),目前 0 筆。MemberDTO):uid、project、username、email、role、type——含電子郵件。plugin/assembly.py:92-94 寫的那句「跳過等於七條端點裸奔(成員清單回全站使用者名冊)」所描述的狀態——只是它防的是「宿主沒注入守門」,而注入了守門的現況也只擋到「有沒有登入」而已。ILIKE 下是否真的成立」——查證結果:成立。 哨兵值是 "__jedi_issue_no_display_name_match__" + "_" * 28(feedback_service.py:47)。共用底層 base_repository_impl.py:135-138 的 __patternize() 規則是:字串已含 % 或 _ 就尊重原值、不再包 %...%。哨兵值含 _,所以直接當 pattern 用。在 ILIKE 裡 _ 是「任一單一字元」的萬用字元,所以這個 pattern 要求比對對象長度恰好等於哨兵長度、且前段字元逐一吻合——created_user 是登入帳號,不可能長成那樣。論證成立,不是缺口。 而且設計動機是對的:不放哨兵會讓條件從 SQL 消失,搜一個不存在的暱稱會從「查無」變成「回全部」。created_user filter 合併時,能不能用 filter 直接指定別人帳號撈他的回饋」——查證結果:能,但這不是新缺口。 FeedbackQueryRequest(serializers/feedback.py:13)開放 created_user 當查詢條件,而底層 _merge_string_filter 把它與暱稱搜出的帳號併成 OR 群組(base_repository_impl.py:235 的 or_(*string_conds))。所以送 {"created_user": "someone"} 就能撈出那個人的全部回饋。但在 F2(誰都看得到全部)的現況下,這只是把「全部」過濾成「某人的」,沒有拿到本來拿不到的資料。 等 F2 修好、加上權限點與「只看自己」的過濾之後,這個 filter 必須一併處理,否則會變成繞過那層過濾的後門。不另計,但列為 F2 修正時的必查項。or_ 群組語意讓「標題含 X」與「建立者含 X」變成 OR,所以同時送標題與建立者兩個條件時,結果會比使用者預期的寬。屬功能語意不屬資安。api/serializers/feedback.py:23-38 的 FeedbackResponse 確實回了 gitlab_issue_uid 與 github_issue_uid——卡片的假設屬實。uid)。| 開卡時已核出 | 本棒工具 | 處置 |
|---|---|---|
| CM-1630 搬家沒修(七條 route 只有匯出守權限、改刪不驗本人) | 重報了(F1+F2) | 標「重複 CM-1630,位置更新」,不另計;建議描述補上成員端點(現況:✅ 已修 FR-114.1-7/CM-2030;成員端點已刪 FR-114.3-4) |
CM-1632 verify=False 兩處仍在 |
重報了(F3) | 標「重複 CM-1632,位置更新」,不另計;⚠️ 越界(屬 J3)(現況:✅ 已修 CM-2066) |
| 六張舊表零隔離(門上了鎖、牆是空的) | 未報 | ①⑤人工查證確認,歸總表第 7 項 |
| 守門殼只驗兩項必填、10 顆權限點 6 顆自陳不守 | 未報 | ②人工查證,設計正確非漏洞;權限點那半歸 CM-1630 |
工具重報了前兩件,都已按規則標重複,沒有膨脹計數。
12 票全投出、沒有漏投,四條全部 3:0。首腦逐條開檔核對(route 的守門、app 層三支方法的實作、序列化欄位、匯出的資料組裝、前端的 canEdit)全部對得上,F1 還額外從前端那側取得反證(前端有擁有者判斷、後端沒有)。
最低強度快篩、一位研究員、不做盤點不做威脅建模。卡片七個重點工具只碰到三個,②⑤⑥⑦ 全部由首腦回頭開檔並連 DEV 資料庫唯讀查證補上——其中 ⑤(成員名單端點回全站名冊)是工具完全沒碰、人工查出來的新發現。與 FR-097 L1/L2、FR-098 A1/A2 撞到的教訓完全一致:低強度掃描會咬住最顯眼的攻擊模式,然後停在那裡。
另外,本套件的註解密度極高且處處自我辯護(每個可疑設計都附一段說明為什麼安全)。實際核對下來,那些辯護多數是對的(哨兵值論證、守門殼的吵鬧拒絕、_attach_issues 的批次撈法),但也正因如此,工具很容易被說服而停止追問——②⑥兩項的結論雖然與註解一致,是首腦獨立驗過才寫下的,不是採信註解。
| 項目 | 數字 |
|---|---|
| 檢查範圍 | 31 個檔案(逐檔比對與卡片 scope 完全一致) |
| 檢查強度 | 最低(low),未設定 focus |
| 候選問題 → 去除重複 | 4 → 4 |
| 投票數 | 12(4 條候選 × 3 位檢查員),全數投出 |
| F1/F2/F3/F4 投票結果 | 全部 3:0 |
| 被駁回候選 | 0 |
| 沒投到票的/投票中斷的 | 0 / 0 |
| 被降低嚴重度的 | 1(F1 由 HIGH 降 MEDIUM) |
| 研究員派出/回收 | 1 / 1 |
| 驗證章狀態 | verified(無拒收理由) |
| 掃描耗時 | 3177 秒(約 53 分) |
| 掃描當下的程式碼版本 | commit 4c208d401c01,branch feature/review |
| workflow run | wf_c2e430d6-115 |
| 工具產物位置 | jedi-issue/CLAUDE-SECURITY-20260916-064039/(該目錄有自己的 .gitignore,不入版控) |
| 首腦人工補查項目 | 卡片七個重點逐項查證,②⑥⑦ 判定不成立、⑤ 為新發現、①③ 補足工具未碰的部分 |
| 對總表的影響 | 淨新增 1 條中(F4 匯出公式注入);F1/F2 併 CM-1630、F3 併 CM-1632、⑤與跨租戶那半歸第 7 項 |