掃描日期:2026-09-06 掃描版本:jedi-python-package
feature/FR-075@67cb075941bd6fd57f1e05a76bc56d5fed984c70(乾淨,無未 commit 變更) 工具:Claude Codeclaude-securityplugin v0.10.2.3(claude-security:scanworkflow) 範圍:jedi-license-runtime/jedi_license_runtime/的api/app/domain/infra四個目錄 +plugin.py,33 個受版控檔案(啟動前用git ls-files驗過,與卡片數字吻合) effort:low,focus:attack-surface模型:主 session Opus 5 (1M context),研究員繼承 狀態:✅ 流程完整跑完,驗證面板verification.status: verified(讀CLAUDE-SECURITY-REVISION-67cb075941bd.json的verification欄確認,非自述) 對應卡片:CM-1568(母卡 CM-1566)
掃描工具在本棒範圍內找到 1 條(TOCTOU 跨租戶重複用照,LOW,屬實但修法要改);另 1 條是 L1 已報過的同一個洞(越界,不計本棒)。此外,我在人工核對過程中另外發現 1 條掃描沒報的問題:停權可以被租戶自己上傳一張新照解除——這條的嚴重度高於掃描報出來的任何一條。
| 項目 | 數字 |
|---|---|
| 研究員派出 / 回報 | 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: 33、emptyScope: false、collapsed: null、droppedComponents: []、unverifiedByCap: 0 |
「Opus 1M + 小範圍」組合再次驗證成立:33 檔(比 L1 的 10 檔多兩倍)一次跑完、零 stalled。對照 FR-075 的 S4~S7 用 Sonnet 5(200K)跑 40 檔以上四棒全滅,本設定可繼續用於 L3/L4。
掃描存活的 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 的固有行為而非偶發。
嚴重度:HIGH | 信心:HIGH(已逐檔開檔核對完整鏈路)| CWE-863(授權判定不正確)
位置:app/service/license_verification_service.py:188-213(_store_verified_license 建新照 entity)+ domain/service/tenant_license_domain_service.py:37(replace_current)
平台管理員可以「停權」某個租戶——例如客戶欠款、或發現濫用。停權的做法是在那張照的資料列上蓋一個 suspended_at 時間戳,被停權的租戶整站變唯讀(common/authz/license.py:275 對 status 與 suspended_at 取聯集)。設計上刻意不動 status,避免每日排程把停權洗掉(CM-1173 的教訓)。
問題出在停權旗標跟著照走,而不是跟著租戶走。而換照的機制(D6 replace 制)是:舊照那一列標成歷史、新建一列當現行照。新建的那一列,suspended_at 是預設的 None。
所以被停權的租戶只要自己上傳一張新照,唯讀立刻解除——不需要管理員、不需要任何特權。
三道看似會擋的機制,全部擋不住:
/license/ 前綴是刻意豁免的(防死鎖設計,api/license/__init__.py 檔頭紅字明示)。被鎖的租戶本來就必須能上傳新照自救。/license/activation/upload 只掛 login_required(api/routes.py:48),任何登入使用者都能打。這同樣是刻意的——開通當下租戶可能還沒有管理員。_store_verified_license 從頭到尾沒有讀過現行照的 suspended_at(我 grep 過整支 license_verification_service.py,suspend 零命中)。四條落地路徑(root 指派/上傳/離線開通/線上開通)都走同一支 _store_verified_license,所以四條全中;其中三條是租戶自己就能打的。
suspended_at 非空)<> tid)排除自己,不會擋/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_suspended 或 landed_while_suspended 事件,讓時間線看得出來。
這一項建議直接開卡修,不要等下一輪掃描——它不會被下一輪掃出來(下一輪的範圍不含這裡)。
嚴重度:掃描判 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_current、idx_tenant_licenses_suspended)都不涉及它。
前提偏嚴苛,掃描判 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 兩列現行照,索引會建不起來——那本身就是這個洞已經被踩到的證據,要先清。
| ID | 面板票數 | 說明 |
|---|---|---|
| F3 | 1:2 否決 | 研究員回報但兩位驗證者不認同;未進最終清單 |
| F4 | 0:3 否決 | 三票全否 |
工作檔(含被否決條目的完整內容)已隨 plugin 的 renderer 清理流程刪除,此處僅保留票數紀錄。
卡片列了七個「首腦讀過程式碼後認為最容易出事的地方」。誠實區分「讀過但沒找到」與「根本沒讀到」:
| 卡片重點 | 結果 |
|---|---|
| 端點守門是否有漏掛 | ✅ 我人工核對過,沒有漏掛。api/routes.py 的 14 條路由每條都標了認證等級,plugin.py:336-338 的迴圈對每條無條件套 decorator,_guard() 對五個 HTTP method 全套。主專案 api/license/__init__.py 注入的 admin_required 是 jwt_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/261、license_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),但沒回報不等於已排除。