L2 掃描結果:License 授權狀態與 API

L2 掃描結果:License 授權狀態與 API

掃描日期:2026-09-06 掃描版本:jedi-python-package feature/FR-075 @ 67cb075941bd6fd57f1e05a76bc56d5fed984c70(乾淨,無未 commit 變更) 工具:Claude Code claude-security plugin v0.10.2.3(claude-security:scan workflow) 範圍jedi-license-runtime/jedi_license_runtime/api / app / domain / infra 四個目錄 + plugin.py33 個受版控檔案(啟動前用 git ls-files 驗過,與卡片數字吻合) effortlowfocusattack-surface 模型:主 session Opus 5 (1M context),研究員繼承 狀態:✅ 流程完整跑完,驗證面板 verification.status: verified(讀 CLAUDE-SECURITY-REVISION-67cb075941bd.jsonverification 欄確認,非自述) 對應卡片:CM-1568(母卡 CM-1566)

§1

一句話結論

掃描工具在本棒範圍內找到 1 條(TOCTOU 跨租戶重複用照,LOW,屬實但修法要改);另 1 條是 L1 已報過的同一個洞(越界,不計本棒)。此外,我在人工核對過程中另外發現 1 條掃描沒報的問題:停權可以被租戶自己上傳一張新照解除——這條的嚴重度高於掃描報出來的任何一條。

§2

執行概況

項目 數字
研究員派出 / 回報 2 / 2(無 stalled,無重試)
候選發現 5 條(去重後 4)
面板票數 12 票(4 條 × 3 票,無缺票)
面板存活 2 條(F3 以 1:2、F4 以 0:3 被否決)
總耗時 約 90 分鐘(5,394 秒)
agent 總數 14(全數完成,0 失敗、0 空回報)
token 消耗 約 167 萬
掃描覆蓋 scopeFiles: 33emptyScope: falsecollapsed: nulldroppedComponents: []unverifiedByCap: 0

「Opus 1M + 小範圍」組合再次驗證成立:33 檔(比 L1 的 10 檔多兩倍)一次跑完、零 stalled。對照 FR-075 的 S4~S7 用 Sonnet 5(200K)跑 40 檔以上四棒全滅,本設定可繼續用於 L3/L4。

§3

⚠️ 研究員又越界,且這次撞上 L1 已報過的同一條

掃描存活的 2 條裡,只有 1 條是本棒的新發現:

ID 標題 檔案 判定
F1 驗章前無界解壓縮(解壓炸彈) .../common/engine.py:114 越界+重複——common/ 是 L1 的範圍,且 L1 報告已完整記錄過同一條(見 scan-L1-verification-core.md F1)
F2 跨租戶重複用照的 TOCTOU .../app/service/license_verification_service.py:142 在範圍內,本棒唯一收穫

F1 不重複論述,修法照 L1 報告。這是研究員第三次越界(FR-075 S7、FR-076 L1、本棒),已可視為 plugin 的固有行為而非偶發。


§4

🔴 額外發現(掃描沒報,人工核對時發現):租戶可以自己上傳一張新照解除停權

嚴重度:HIGH | 信心:HIGH(已逐檔開檔核對完整鏈路)| CWE-863(授權判定不正確)

位置app/service/license_verification_service.py:188-213_store_verified_license 建新照 entity)+ domain/service/tenant_license_domain_service.py:37replace_current

白話說明

平台管理員可以「停權」某個租戶——例如客戶欠款、或發現濫用。停權的做法是在那張照的資料列上蓋一個 suspended_at 時間戳,被停權的租戶整站變唯讀(common/authz/license.py:275statussuspended_at 取聯集)。設計上刻意不動 status,避免每日排程把停權洗掉(CM-1173 的教訓)。

問題出在停權旗標跟著照走,而不是跟著租戶走。而換照的機制(D6 replace 制)是:舊照那一列標成歷史、新建一列當現行照。新建的那一列,suspended_at 是預設的 None

所以被停權的租戶只要自己上傳一張新照,唯讀立刻解除——不需要管理員、不需要任何特權。

為什麼繞得過守門

三道看似會擋的機制,全部擋不住:

  1. 唯讀 gate 不擋/license/ 前綴是刻意豁免的(防死鎖設計,api/license/__init__.py 檔頭紅字明示)。被鎖的租戶本來就必須能上傳新照自救。
  2. 端點權限不擋/license/activation/upload 只掛 login_requiredapi/routes.py:48),任何登入使用者都能打。這同樣是刻意的——開通當下租戶可能還沒有管理員。
  3. 落地流程不檢查停權_store_verified_license 從頭到尾沒有讀過現行照的 suspended_at(我 grep 過整支 license_verification_service.pysuspend 零命中)。

四條落地路徑(root 指派/上傳/離線開通/線上開通)都走同一支 _store_verified_license,所以四條全中;其中三條是租戶自己就能打的。

觸發前提

  • 租戶被平台管理員停權(suspended_at 非空)
  • 租戶手上有任何一張驗章通過、且不是當前現行照的照
    • 最容易的來源:自己的舊照。歷史照仍屬本租戶,跨租戶檢查(<> tid)排除自己,不會擋
    • host 版還要過機器指紋核對,但舊照本來就綁同一台機器
  • 送一個 POST 到 /license/activation/upload

實際影響

停權這道管理手段對「手上有舊照的租戶」形同虛設。SaaS 版尤其嚴重:force_lock_tenant 的 docstring 明說這是「SaaS 獨有手動立即停權」,是欠款/濫用時的主要處置工具,而 SaaS 版連機器指紋核對都沒有(deployment_mode != "host" 直接跳過)。管理員停權後看到租戶恢復正常,時間線上只會出現一筆正常的 upload 事件,不會有任何告警。

建議修法

不要只在落地路徑補一個 if——那只擋得住這一條路,日後新增落地路徑會再漏一次。停權語意本來就屬於租戶而不屬於某一張照,兩個方向擇一:

  • (建議)把停權移到租戶層:新增 config.tenant_license_suspensions(或在租戶側記一個旗標),執法讀租戶而非讀照。換照就自然不會清掉它。
  • (次選,改動小)落地時繼承_store_verified_license 建新 entity 前先讀現行照,若 suspended_at 非空就帶到新列,並記一筆事件。但要留意這會讓「停權中的租戶換照」永遠帶著停權,解除只能靠管理員 unlock——這其實正是想要的語意,只是要明確寫進設計。

無論選哪個,落地路徑都應該補一筆 blocked_while_suspendedlanded_while_suspended 事件,讓時間線看得出來。

這一項建議直接開卡修,不要等下一輪掃描——它不會被下一輪掃出來(下一輪的範圍不含這裡)。


§5

F2(掃描報出、範圍內唯一收穫)— 跨租戶重複用照的競態視窗

嚴重度:掃描判 LOW | 信心:MEDIUM | CWE-367(TOCTOU)人工核對:屬實,但修法要改

位置app/service/license_verification_service.py:142_store_verified_license 的跨租戶檢查)+ infra/license_tenant_resolver.py:_query_scalar

白話說明

同一張照不該被兩個租戶同時用——不然 SaaS 版 root 手滑把 A 客戶的照指派給 B,B 就免費用 A 的授權(程式碼註解 CM-1157 講得很清楚)。目前擋這件事的方法是:落地前查一下「這個 license_id 有沒有被別的租戶用過」,有就拒絕。

問題是這個「查」跟接下來的「寫」不在同一個交易裡。查詢走 LicenseTenantResolver._query_scalar,它開一個全新的 session、查完就 rollback 關掉(這是為了繞 RLS 讀跨租戶事實,設計上有理由);而寫入是在呼叫端那個還沒 commit 的交易裡。

於是兩個租戶同時上傳同一張照時,兩邊都在對方 commit 之前查,兩邊都看到「沒人用過」,兩邊都寫進去。而資料庫層沒有任何 unique 約束能事後補救——我開了 migrations/001-license-tables.sql 核對,license_id 欄位(第 32 行)只是普通 VARCHAR(64),兩支 index(idx_tenant_licenses_tenant_currentidx_tenant_licenses_suspended)都不涉及它。

觸發前提

  • 攻擊者在同一套系統的兩個租戶各有登入帳號(或跟另一個租戶的人串通)
  • 兩邊拿同一張驗章通過、且還沒在任何租戶落地過的照
  • 兩個請求要卡在「查完」到「commit」之間的窄視窗內

前提偏嚴苛,掃描判 LOW 合理。

掃描給的修法有問題,不要照抄

掃描建議「加一個 UNIQUE(license_id)」。這會擋掉正當流程:D6 是 replace 制,同一個租戶換回舊照時會為同一個 license_id 再插一列(新列 is_current=true,舊列留著當歷史)。全表唯一索引會直接讓這條路炸掉,而「換回舊照」是程式碼註解明文承認的正當語意(_store_verified_license 第 118-119 行)。

建議改成:在 license_id 上加一個部分唯一索引,只約束現行照——

CREATE UNIQUE INDEX CONCURRENTLY idx_tenant_licenses_license_id_current
    ON config.tenant_licenses (license_id) WHERE is_current = true;

這樣「一張照同時只能有一個租戶正在用」由資料庫保證,而歷史列不受影響。落地時接住 IntegrityError 轉成既有的 LICENSE_ALREADY_USED_BY_OTHER_TENANT

⚠️ 建索引前要先查現有資料:如果 DEV/STG/POC 現在就有同一 license_id 兩列現行照,索引會建不起來——那本身就是這個洞已經被踩到的證據,要先清。


§6

面板否決的兩條(記錄備查,不列為發現)

ID 面板票數 說明
F3 1:2 否決 研究員回報但兩位驗證者不認同;未進最終清單
F4 0:3 否決 三票全否

工作檔(含被否決條目的完整內容)已隨 plugin 的 renderer 清理流程刪除,此處僅保留票數紀錄。


§7

卡片點名的七個重點,各自結果如何

卡片列了七個「首腦讀過程式碼後認為最容易出事的地方」。誠實區分「讀過但沒找到」與「根本沒讀到」:

卡片重點 結果
端點守門是否有漏掛 我人工核對過,沒有漏掛api/routes.py 的 14 條路由每條都標了認證等級,plugin.py:336-338 的迴圈對每條無條件套 decorator,_guard() 對五個 HTTP method 全套。主專案 api/license/__init__.py 注入的 admin_requiredjwt_required() + require_platform_admin_route五支 login 級端點是刻意的防死鎖設計,不是漏掛——但正是它讓上面那條停權繞過成立
跨租戶(能否把照塞進別人租戶) ✅ 有擋,但擋法有競態(=F2)。寫入租戶取自 JWT(ctx.tenant_id),呼叫端指定不了
同一張照多租戶/多機器重放 ⚠️ 多租戶=F2;多機器由機器指紋擋,但僅 host 版,SaaS 版設計上不綁機器
狀態機的卡住/跳過路徑 ⚠️ 找到一條:停權可被換照清掉(=上面的額外發現)。suspended_at 的清空條件我追過了——正規路徑只有 unlock_tenant(admin 級),但換照建新列等於變相清空
兩支 migration 與 RLS 讀過,policy 形狀正確(super-admin bypass + allowed_tenant_paths 前綴比對)。與 jedi-common fail-open(CM-1559)的疊加效果未做針對性推演——那屬 jedi-common 的問題,不在本棒範圍
向 LC 請照的 SSRF 不成立。三處 outbound 呼叫(tenant_license_admin_service.py:222/261license_verification_service.py:322)的 base URL 全部來自 LicenseConfig.activation_server_url(部署設定),path 是程式碼寫死的字串常數,呼叫端指定不了。API token 同樣來自設定
plugin.py 的預設值 fail-open 還是 fail-closed 研究員沒產生候選。我順手看了幾個關鍵的:is_platform_admin / licensed_job_types 未注入時降級為 False / 空清單(fail-closed,正確);但 deployment_mode 預設 "saas" 是 fail-open 方向——落地版若沒設對,機器綁定靜默失效api/license/__init__.py 已用紅字警告,屬已知並接受的設計)

未涵蓋的部分low effort 只派研究員通掃,不保證對每個假設做過針對性推演。上表中標「讀過但沒找到」的項目,檔案確實在研究員讀取範圍內(33 檔全在 scope),但沒回報不等於已排除。


§8

給決策者的三件事

  1. 停權繞過(額外發現)建議直接開卡修,優先於掃描報出的兩條。它是本棒最有實際影響的發現,而且掃描沒抓到——下一輪也不會抓到。
  2. F2 若要修,不要用掃描建議的全表唯一索引,改用上面的部分唯一索引;建索引前先查三環境有無既存重複列。
  3. F1 不必重複處理,L1 已完整記錄。