掃描日期:2026-09-06 掃描版本:license_center
feature/FR-075@6c1d9160380cf23b8bb70a522dca4c91506146f9(乾淨,無未 commit 變更) 工具:Claude Codeclaude-securityplugin v0.10.2.3(claude-security:scanworkflow) 範圍:src/license_center/web,43 個受版控檔案(啟動前用git ls-files驗過,與卡片數字吻合;已排除static/vendor) effort:low,focus:attack-surface模型:主 session Opus 5 (1M context),研究員繼承 狀態:✅ 流程完整跑完,驗證面板verification.status: verified(讀CLAUDE-SECURITY-REVISION-6c1d9160380c.json的verification欄確認,非自述) 對應卡片:CM-1571(母卡 CM-1566)
範圍內找到 4 條 MEDIUM,全部屬實,沒有 HIGH/CRITICAL。 問題全部集中在線上開通端點(/api/activation/activate,唯一免認證的對外入口)與登入導轉兩處: 開通序號只有 32 bits 且防猜機制形同虛設(可暴力猜到別人的照)、防猜用的記憶體結構無上限(可打掛服務)、 登入後的 next 參數未驗證(可用來釣內部人員的管理密碼)。
值得注意的是:同一個 repo 內就有正確做法——後台登入的失敗鎖定存 DB、計 IP、 docstring 還明寫了「為什麼不能存記憶體」,但開通端點沒套用同一套知識。這不是不懂,是遺漏。
| 項目 | 數值 |
|---|---|
| 掃描時長 | 約 110 分鐘(06:18 啟動 → 07:29 產出報告,含面板) |
| agent 派出/回報 | 23 派出,23 回報,0 失敗、0 stalled |
| 研究員 | 派出 2,回報 2 |
| 原始候選 | 9 條 → 去重後 7 條 |
| 面板投票 | 21 票(7 條 × 3 位),全部投完,無漏審、無被 cap 砍掉 |
| 通過(3-0) | 4 條(本報告 F1~F4) |
| 遭否決 | 3 條(1 條 1-2、2 條 0-3,詳見末節) |
| 消耗 token | 約 240 萬(subagent 合計) |
面板確實跑完:verification.status: verified、unreviewed_candidate_sites: 0、 incomplete_panel_candidates: 0、candidatesDroppedByCap: 0。這幾個欄位是 workflow 自己 用程式算出來寫進 stamp 的,不是我或 agent 自述。
「Opus 1M + 小範圍」再次驗證成立:本棒 43 檔(四棒中最大),110 分鐘一次跑完零 stalled。 對照 FR-075 用 Sonnet 跑 40 檔以上四棒全滅共燒 29.6 小時。
掃了:src/license_center/web 整棵——app factory、session 登入、CSRF 接線、安全 header、 後台各 SSR 頁面(總覽/簽發/方案/照明細/客戶時間線/解鎖 token/API token 管理)、 兩支機器 API(/api/internal/*、/api/activation/*)、健康檢查、模板與靜態資產。
沒掃:src/license_center/web 以外的一切——包含 core/(組態、金鑰處理、logging)、 modules/(licensing/auth/plan 領域服務)、cli/、scripts/、alembic/、tests/。 這對讀本報告有實際影響:F2 的建議修法要動 modules/licensing/service.py:42、 F4 要動 web/api_schemas.py 與 core/config.py,但只有範圍內的呼叫點被審過。
越界發現已排除:研究員有 2 條指向 src/license_center/core/config.py:124 (LICENSE_CENTER_ENV 未設定時預設 dev,連帶套用公開已知的預設 SECRET_KEY)。 該檔不在本棒 scope 內,依卡片紀律標為越界、不列為本棒發現—— 且該 2 條在面板也是 0-3 被否決。若首腦認為值得追,應另開範圍涵蓋 core/ 的棒次。
低 effort 的意義:low 沒有 inventory、沒有 threat model、沒有 breadth sweep, 就是一輪研究員讀完再交面板。所以「某區沒發現」的正確讀法是 「有讀過但沒報東西」,不是「已窮盡稽核」。這與「根本沒讀到」(上述範圍外)是兩回事。
| # | 嚴重度 | 位置 | 一句話 | 面板 | 復核 |
|---|---|---|---|---|---|
| F1 | MEDIUM | web/auth.py:83-84 |
登入後 next 參數未驗證,可導到外站釣密碼 |
3-0 | 屬實 |
| F2 | MEDIUM | web/views/activation.py:115 |
開通序號防猜鎖定「用被猜的序號當 key」,等於沒鎖 | 3-0 | 屬實(更嚴重) |
| F3 | MEDIUM | web/views/activation.py:53 |
防猜計數表無上限,免認證即可撐爆記憶體 | 3-0 | 屬實(與 F4 同源) |
| F4 | MEDIUM | web/views/activation.py:66 |
同上結構,且查詢時也寫入、key 長度無限制 | 3-0 | 屬實(與 F3 同源) |
位置:src/license_center/web/auth.py:83-84,register_auth.login 嚴重度:MEDIUM 信心:高 CWE-601 面板:3-0
問題在說什麼(白話) 登入頁的網址可以帶一個 ?next=... 參數,用意是「登入完把你送回原本要去的頁面」。 但程式碼拿到這個值之後完全沒檢查就直接導轉:
next_url = request.args.get("next") or url_for("overview.index")
return redirect(next_url)所以 next 填 https://evil.tld/login 也照導。Werkzeug 不會幫忙擋, //evil.tld 這種協定相對網址一樣有效。
實際影響 攻擊者寄一個連結給內部人員:http://<簽發站>:5062/login?next=https://假站/login。 受害者看到的是真站的登入頁(網址列是對的、憑證是對的),輸入帳密、登入成功, 然後被導到攻擊者的仿冒頁,那頁顯示「連線逾時請重新登入」——密碼就被收走了。 而簽發站只有一組共用管理帳密,拿到就等於拿到簽照、簽解鎖 token、 以及全部客戶與訂單資料的權限。
觸發前提
建議修法 在導轉那一行檢查,不要在填 next 的呼叫端檢查:用 urlsplit() 解析, 只要 scheme 或 netloc 有值就丟掉、退回 url_for("overview.index"); 另外要求開頭是單一個 /(擋掉 // 與 /\)。
復核(我開檔核對過):屬實。 auth.py:83-84 逐字如報告所述。一個補充:導轉發生在登入成功之後, 所以攻擊者無法靠它繞過登入或直接進後台;危害路徑純粹是釣魚 (讓受害者在真站登入後落到假站再輸一次)。MEDIUM 的定級合理,不需上調。
位置:src/license_center/web/views/activation.py:115,activate 嚴重度:MEDIUM 信心:高 CWE-307 面板:3-0
問題在說什麼(白話) /api/activation/activate 是故意不做 token 認證的端點——呼叫端是客戶的後端, 端點自己的註解寫得很清楚:「本端點無 token 認證——憑證就是開通序號本身」。 既然序號就是憑證,那防止有人亂猜序號就是唯一的防線。
現在的防線是:同一個序號 15 分鐘內失敗 5 次就鎖定。 但鎖定的 key 就是「被猜的那個序號」——攻擊者每次猜不同的值, 每個 key 的失敗次數永遠是 1,門檻永遠不會到。沒有按來源 IP 計數,也沒有全域上限。
而序號本身是 uuid.uuid4().hex[:8].upper()——8 個十六進位字元,32 bits。
實際影響 猜中一個「已簽發但客戶還沒開通」的序號(這是簽發到交付之間的正常狀態), 攻擊者就能用自己指定的機器指紋去換一張真的、有簽章的授權照—— 等於免費拿到整套產品的使用權。同時那個序號會被燒掉, 真正的客戶之後開通會拿到 409 失敗。
觸發前提
/api/activation/activate建議修法 ① 改成對「攻擊者無法變動的東西」計數——按 request.remote_addr 計,再加一個全域失敗預算; ② 計數存 DB 不存記憶體(見下方復核); ③ new_activation_code()(modules/licensing/service.py:42)的熵要拉高, 32 bits 對一個單獨當憑證用的值太少,改 secrets.token_urlsafe。
復核(我開檔核對過):屬實,而且比工具寫的更值得注意。 三點逐一核對:
activate() 確實只呼叫 _is_locked_out(activation_code),全檔沒有任何按 IP 的計數 ——request.remote_addr 只進 log,不進判斷。
new_activation_code() 確為 uuid.uuid4().hex[:8].upper(),32 bits 無誤。
對照組就在同一個 repo 裡:web/auth.py 的後台登入鎖定是存 DB 的, 而且它的 docstring 明寫:「計數存 DB 不存記憶體——gunicorn 多 worker 記憶體不共享, 存 process 內等於把門檻乘上 worker 數,且重啟即清空。」
同一份知識已經寫在隔壁檔案,開通端點卻沒有套用。 這不是「不知道要這樣做」,是遺漏。開修正卡時可以直接指這段當範本。
位置:src/license_center/web/views/activation.py:53(_record_failure) 與 :66(_is_locked_out) 嚴重度:MEDIUM 信心:高 CWE-770 面板:各 3-0
工具把這兩條分開報,但它們是同一個 dict 的兩個寫入點,修法共用一套。 這裡合併說明,開修正卡時應開成一張卡,不要拆兩張。
問題在說什麼(白話) 防猜用的計數表 _failure_log 是一個模組層級的 dict, key 就是呼叫端送來的 activation_code——也就是攻擊者說了算的字串。 這個 dict 從來不清理:
兩個寫入點的成本差很多:
| 行 | 何時寫入 | 攻擊成本 |
|---|---|---|
:66(_is_locked_out) |
每次請求都寫,而且在查 DB 之前 | 便宜——隨便打就長 |
:53(_record_failure) |
只有走到失敗分支(404/409/400)才寫 | 較貴 |
雪上加霜的是key 的長度沒有上限: ActivateRequestSchema.activation_code 是純 fields.Str,沒有 Length 驗證; create_app() 也沒有設 MAX_CONTENT_LENGTH(全 repo grep 確認過,一處都沒有)。
實際影響 攻擊者送 {"activation_code": "<10 MB 的 A>", "machine_fingerprint": "x"}, _is_locked_out 會在查資料庫之前先把這 10 MB 的字串當 key 存進去,而且永遠不刪。 幾十個這種請求就能吃掉數百 MB。 worker 被 OOM 殺掉之後, 簽發、線上開通、竄改回報、後台操作全部停擺。整個過程不需要任何憑證。
觸發前提
/api/activation/activate(依設計免認證、且豁免 CSRF)建議修法(四項一起做)
_is_locked_out 改用 _failure_log.get(key, []),只在 _record_failure 寫OrderedDict 當 LRU 設硬上限,寫入時順手掃掉過期的 key(不是只掃當前 key)ActivateRequestSchema.activation_code 加 ma.validate.Length(max=64)MAX_CONTENT_LENGTH:在 create_app() 設定,超大 body 在進 handler 之前就被擋掉若採用 F2 建議的「計數改存 DB」,本條的 1、2 兩項自然一起解決。 兩個寫入點只補一邊等於沒補——第 66 行是主要的便宜攻擊路徑。
復核(我開檔核對過):兩條都屬實。 _is_locked_out 第 66 行確為無條件寫入且執行在 DB 查詢之前; _required_str 只有 required=True 無長度上限; 全 repo grep MAX_CONTENT_LENGTH 零結果。
| 位置 | 宣稱 | 票數 | 處理 |
|---|---|---|---|
web/views/internal_api.py:87 |
單一 API token 同時授權簽照與簽 manifest,無 scope 區分 | 1-2 | 否決(範圍內,但面板多數認為不成立) |
core/config.py:124 ×2 |
LICENSE_CENTER_ENV 預設 dev → 套用公開已知的預設 SECRET_KEY |
0-3 | 越界(core/ 不在本棒 scope)+面板否決 |
internal_api.py:87 那條在範圍內,我看過該處程式碼:token 檢查是掛在 @bp.before_request 上的全 blueprint 認證,且註解說明了「認證先於參數驗證」的理由。 面板 1-2 否決,我不推翻——但「單一 token 無 scope 區分」屬設計取捨而非漏洞, 若首腦認為權限應分離,那是需求層的討論,不該用資安卡承載。
| 建議卡 | 收哪幾條 | 理由 |
|---|---|---|
| 卡 A:開通端點防猜與資源上限 | F2 + F3 + F4 | 三條都在同一支檔案的同一段邏輯,且 F2 建議的「計數改存 DB」順手解掉 F3/F4 的一半。拆開修會互相踩到。 |
| 卡 B:登入導轉驗證 | F1 | 獨立、單點、幾行就好,與卡 A 無耦合。 |
卡 A 的範本就在同 repo:web/auth.py + modules/auth/(DB 計數、按帳號鎖定、 docstring 寫明為什麼不能存記憶體)。修的人照著搬即可,不需重新設計。
優先序判準(依 [[project_first_release_is_host_saas_reserved]]): 兩張卡影響的都是簽發站——它是公司內部系統,不隨落地版出貨給客戶。 但 /api/activation/activate 是客戶端 BE 會打的對外端點, 落地版客戶的網路打得到它,所以卡 A 的曝險面比卡 B 大。
掃描原始產物在 license_center repo(該目錄自帶 .gitignore,不入版控):
license_center/CLAUDE-SECURITY-20260906-051823/
├── CLAUDE-SECURITY-RESULTS.md # 工具產出的英文報告
├── CLAUDE-SECURITY-RESULTS.jsonl # CI gate 用
├── CLAUDE-SECURITY-RESULTS.sarif # code scanning dashboard 用
└── CLAUDE-SECURITY-REVISION-6c1d9160380c.json # 版本與驗證戳記
本文件是該報告的中文整理+逐條開檔復核,不是翻譯—— 每條的「復核」段是我自己讀原始碼後的判斷,與工具輸出獨立。