掃描日期:2026-09-06 掃描版本:license_center
feature/FR-075@6c1d9160380cf23b8bb70a522dca4c91506146f9(乾淨,無未 commit 變更) 工具:Claude Codeclaude-securityplugin v0.10.2.3(claude-security:scanworkflow) 範圍:src/license_center/core、src/license_center/modules、src/license_center/cli,32 個受版控檔案(啟動前用git ls-files驗過,與卡片數字吻合) effort:low,focus:attack-surface模型:主 session Opus 5 (1M context),研究員繼承 狀態:✅ 流程完整跑完,驗證面板verification.status: verified(讀CLAUDE-SECURITY-REVISION-6c1d9160380c.json的verification欄確認,非自述) 對應卡片:CM-1569(母卡 CM-1566)
範圍內找到 2 條(1 HIGH、1 MEDIUM),全部屬實。
兩條講的是同一件事的兩面:開通序號只有 8 個字、而且它整串會被寫進 log。
開通序號是「客戶拿它去換一張簽好的授權照」的唯一憑證——那支端點刻意不做 token 認證, 序號本身就是密碼。但這個密碼只有 8 個十六進位字(約 43 億種組合), 而防猜機制(L4 已報)拿「被猜的那個序號」當計數 key,所以怎麼猜都不會被鎖。 更糟的是,專門負責「把序號遮起來再寫 log」的那支函式,遮罩寫成保留前 8 碼—— 而序號剛好就是 8 碼,等於整串原封不動印進 journal。
另外 3 條(F3/F4/F5)是研究員讀出範圍外的 web/ 層找到的,L4 那一棒已經全部報過, 此處僅列出並標示重複,不重複開卡。
| 項目 | 數值 |
|---|---|
| 開始 / 結束 | 2026-09-06 05:17 / 07:26 UTC |
| 總耗時 | 約 2 小時 9 分(一次跑完,中間有 1 次 agent stall 自動重試成功) |
| agent 數 | 派出 26、回報 26、失敗 0、空回報 0 |
| 研究員 | 派 2 支(1 主掃 + 1 gap-fill sweep 含 secrets pass),回報 2 支 |
| 原始候選 | 9 條 → 去重後 8 條 |
| 面板 | 8 條全部送審,24 票(每條 3 票),0 條未審 |
| 面板結果 | 保留 5 條(全部 3-0 一致通過)、否決 3 條(全部 0-3 一致否決) |
唯一一次 stall:sweep:secrets 這支 agent 在 2056 秒無進度後被判 stalled, 自動重試第 1 次即成功。這與 FR-075 用 Sonnet 時「反覆 stall 到全滅」是不同性質—— 那是 context 撐不住,這只是單支慢,重試就過。
掃了(32 檔,全部受版控):
core/:密碼學層(crypto/keys.py 私鑰載入、crypto/engine.py 簽章與驗章、crypto/manifest.py)、 設定、DB、logging、schema、狀態、版本、模組目錄modules/:licensing(簽發/展延/補發/開通/換綁)、auth(後台登入失敗鎖定)、 integrity(manifest)、tamper_report(竄改回報留存)cli/:main.py 全部子命令沒掃:三個指派目錄以外的一切,特別是 src/license_center/web/(Flask views/blueprint/ auth/API schema)、tests/、scripts/、docs/、alembic/。
⚠️ 研究員再次越界:5 條存活發現裡只有 2 條(F1、F2)在指派範圍內, 另 3 條指向 web/ 層。這與 FR-075 S7、FR-076 L1 是同一個模式—— 面板只驗「發現成不成立」、不驗「是否在範圍內」,收報告時要自己挑。 所幸這 3 條落在 L4 的範圍,L4 已經掃過並報過,等於是同一個洞被兩棒獨立找到。
低 effort 的形狀:沒有 inventory、沒有 threat model,一支研究員通掃 + 一次補漏掃。 completenessCheckOutcome 是 not-applicable——這是指定範圍的掃描, 它的「完整」定義就是那 32 個檔,不是整棵樹。
沒有任何東西被 cap 截斷:無 pruned bucket、無未審候選、無 adversarial casualty、無跳過的元件。
沒有執行任何程式碼:沒跑測試、沒發請求、沒實際打端點。全部發現都來自讀原始碼; 我的復核也是開檔比對,不是實測。
| # | 嚴重度 | 位置 | 一句話 | 面板 | 範圍 | 復核 |
|---|---|---|---|---|---|---|
| F1 | HIGH | modules/licensing/service.py:43 |
開通序號只有 8 碼(43 億組),而它是免認證端點的唯一憑證 | 3-0 | ✅ 範圍內 | 屬實 |
| F2 | MEDIUM | core/logging.py:127 |
「遮蔽序號」的函式保留前 8 碼,而序號剛好 8 碼=整串沒遮 | 3-0 | ✅ 範圍內 | 屬實(我認為應升 HIGH,理由見下) |
| F3 | MEDIUM | web/views/activation.py:115 |
防猜鎖定用被猜的序號當 key,等於沒鎖 | 3-0 | ❌ 越界 | 屬實,L4 F2 已報 |
| F4 | MEDIUM | web/auth.py:84 |
登入後 next 未驗證,可導到外站釣密碼 |
3-0 | ❌ 越界 | 屬實,L4 F1 已報 |
| F5 | LOW | web/views/activation.py:53 |
防猜計數表無上限,免認證即可撐爆記憶體 | 3-0 | ❌ 越界 | 屬實,L4 F3/F4 已報 |
位置:src/license_center/modules/licensing/service.py:43,new_activation_code 嚴重度:HIGH 信心:高 CWE-331 面板:3-0
客戶要啟用產品時,公司給他一組「開通序號」,他的後端拿這組序號打簽發站, 換回一張綁定他機器的授權照。這支端點刻意不做 token 認證——設計上, 序號本身就是密碼(程式碼註解寫得很清楚:「本端點無 token 認證——憑證就是開通序號本身」)。
問題是這個「密碼」只有 8 個十六進位字:
def new_activation_code() -> str:
return uuid.uuid4().hex[:8].upper()uuid4() 本身有 122 bits 的隨機性,但砍到前 8 個 hex 字只剩 32 bits, 也就是約 43 億種可能。對照同一個 repo 的 API token 是 secrets.token_urlsafe(32)(256 bits)—— 同一份程式碼裡,會走網路的憑證強度差了 224 bits。
因為攻擊者不需要猜中「某一個特定序號」,只要猜中任何一個還沒被用掉的序號就有收穫。 簽發站每簽一張照就產一組序號(issue_license 無條件產,連 SaaS 那種永遠不會被開通的也產), 所以未使用序號的池子只會愈長愈大。有 N 組活序號時,期望要送 2³²/N 個請求。
而且完全沒有東西擋這些請求:
pyproject.toml 與 src/ 下所有 .py,沒有任何 rate limiter 套件或實作gunicorn -b 0.0.0.0:5062,前面沒有反向代理回應的 body 直接就是一張簽好章的授權照,綁在攻擊者自己的機器指紋上。 照裡面還帶著 customer_code、order_no、issued_to、tenant_id——等於順便洩漏客戶資料。 同時 activated_at 被寫上,原本那個付錢的客戶就再也開通不了(他去打會拿到 409「此開通序號已被使用」)。
/api/activation/activate(依設計就是要給客戶後端打的)secrets.token_hex(16)(128 bits)以上,並全程當機密處理三項要一起做。單獨拉高熵仍讓 F3 的無限猜測存在(只是不划算了); 單獨修 F3 而序號還是 8 碼,則一旦有人繞過 IP 限制(例如殭屍網路)就還是打得穿。
屬實。 逐項開檔核對:
service.py:43 確為 uuid.uuid4().hex[:8].upper(),32 bits 無誤service.py:72 確認 issue_license 無條件呼叫 new_activation_code(), 不論 license type/deployment mode——SaaS 照也會產一組永遠不會被用的序號web/views/activation.py:82-172 確認端點無任何認證 decorator, 對照 internal_api.py:78 的 @bp.before_request _authenticate()(走 X-API-Token 白名單)—— 一個有守門一個沒有,是刻意的設計差異grep -rn -E "limiter|Limiter|ratelimit" pyproject.toml $(find src/license_center -name '*.py') → 零筆activation.py:172 確認成功時 return jsonify({"license": result.signed}), 200, 照直接回在 bodyservice.py:250-291 確認 activate_license 只用序號查(get_by_activation_code), 不比對租戶、不比對訂單、不比對任何第二因素,然後直接用私鑰簽一個對比值得寫下來:同 repo 的 api_tokens.py:46 用 secrets.token_urlsafe(32) 產 API token、 只存 sha256 hash、DB 加 UNIQUE。這個 repo 知道怎麼做對,開通序號沒套用同一套標準。
位置:src/license_center/core/logging.py:127,activation_code_digest 嚴重度:MEDIUM(工具判定)/我認為應升 HIGH 信心:高 CWE-532 面板:3-0
程式碼裡有一支專門的函式,職責就是「把開通序號遮起來再寫 log」—— 它的 docstring 白紙黑字寫著「未使用的開通序號等同憑證,同樣屬安全紅線不入 log 明文」。
但實作是這樣:
digest = hashlib.sha256(activation_code.encode("utf-8")).hexdigest()[:8]
return f"{activation_code[:8]}...{digest}"保留前 8 碼當「前綴」,讓維運可以 grep 同一組序號的多筆 log。 docstring 解釋這樣安全,因為「前綴本身通常太短,猜不出剩餘字元」。
問題是序號總長就是 8 碼(F1 那行程式)。activation_code[:8] 不是前綴,是全部。 所以這支「遮蔽函式」實際上把完整憑證原封不動寫進 journal, 而所有呼叫端都以為自己已經遮過了。
兩個地方各自看都「有道理」:8 碼夠短所以取全部沒關係(logging 的假設)、 序號夠亂所以不必更長(licensing 的假設)。兩個假設互相依賴、卻沒有任何一處把它們對起來。 這正是為什麼修 F1(把序號拉長到 32 碼)會意外地讓 F2 從「全洩」變成「洩前 8 碼」—— 但那是巧合不是修正,兩條要分別修。
INFO(core/config.py:42),而成功開通那條就是 logger.info (activation.py:169),所以正常運作就在洩views/activation.py:58,118,127,133,142,169(免認證 API)、 views/issuance.py:251(後台手動受理)、cli/main.py:305(離線開通)activation.py:127(找不到序號)與 :142(授權已過期)—— 這兩條記的是「還沒被兌換」的序號,也就是還能用的活憑證RequestIdFilter(logging.py:53-63),它只加 request_id,不做任何遮蔽拿到 log 的人(維運、log 轉送管線、工單附件、log 備份)等於拿到可直接兌換的憑證。
工具給 MEDIUM 的理由是「需要先有 log 存取權」。這個門檻在本專案的實際脈絡下很低:
不過我保留工具的 MEDIUM 標記,因為嚴重度分級的最終判準在決策者, 而「需要一個立足點」確實是真的。修正優先序上我建議與 F1 同一張卡、同時修。
tests/test_logging_setup.py:131 有一個測試要驗這支函式會遮蔽, 但它餵的字串是 18 個字——永遠不會踩到「序號剛好等於前綴長度」這個真實情況。 所以這個洞從寫下來到現在,測試一直是綠的。
hmac_sha256(secret, code)[:8]license_id。維運要的是「把同一組序號的 log 串起來」, 而每個呼叫點手上都有 license_id,它不是憑證、天生就適合當關聯 key屬實。 核對紀錄:
core/logging.py:117-127 逐行讀過,return f"{activation_code[:8]}...{digest}" 一字不差service.py:43 對照確認序號長度恰為 8——fingerprint_digest(同檔 106-114)取前 12 碼 是合理的(機器指紋長得多),問題只出在序號這一支core/config.py:42 確認 DEFAULT_LOG_LEVEL = "INFO"activation.py 五個呼叫點逐一開過,其中 :127/:142 記的確實是未兌換序號logging.py:53-63 確認 RequestIdFilter 只 record.request_id = ...,無遮蔽邏輯另外自己查證的一點(研究員與面板都提到、我實際追過): reissue_license(service.py:225)補發時會把舊序號複製到新列, 而新列的 activated_at 是 None(沒有寫入)。加上 get_by_activation_code (repository.py:122-134)取 created_at DESC 的最新一筆—— 這代表一組曾經被用掉、且曾經寫進 log 的序號,在補發後會復活成可用狀態。 這讓 F2 的影響不限於「還沒兌換的序號」,連歷史 log 裡的舊序號都可能重新生效。
這三條研究員讀出了指派範圍,落在 src/license_center/web/。 L4(CM-1571)已經系統性掃過該範圍並全部報過,對應關係:
| 本棒 | L4 對應 | 內容 |
|---|---|---|
F3(activation.py:115) |
L4 F2 | 防猜鎖定用被猜的序號當 key |
F4(auth.py:84) |
L4 F1 | 登入 next 未驗證可導外站 |
F5(activation.py:53) |
L4 F3/F4 | 失敗計數表無上限、查詢時也寫入 |
三條我都開檔確認屬實(_failure_log 確實以 activation_code 為 key、 remote_addr 只出現在 log 字串裡;auth.py:83-84 確實 request.args.get("next") 直接進 redirect())。 已由 CM-1583 與 CM-1584 涵蓋,不另開卡。
值得記一筆的是:兩棒獨立掃描、獨立面板,對同樣三個洞給出一致結論, 這算是對掃描結果可信度的一次側面驗證。
| 候選 | 位置 | 否決票 | 否決理由(我複核後同意/不同意) |
|---|---|---|---|
| 驗章前的無界解壓縮(解壓炸彈) | core/crypto/engine.py:76 |
0-3 | 同意否決(就 LC 而言)——見下方說明 |
session cookie 預設無 Secure 屬性 |
core/config.py:130 |
0-3 | 同意否決——整個部署根本沒有 TLS,加了這個旗標不會多擋任何東西,反而會讓登入壞掉(部署文件 §4 已明文警告) |
| 測試 fixture 內有真實開通序號 | tests/test_activation_guidance.py |
0-3 | 同意否決為漏洞、但保留為衛生問題——是 DEV 實測紀錄(commit 125caf5 標明「DEV 實測」),在 STG/POC 查無此列會回 404;但真實憑證進版控仍不是好習慣 |
這條值得特別說明,因為 L1(jedi-license-runtime)把同一個議題判成 HIGH 並開了 CM-1572, 而這裡面板判 FALSE_POSITIVE,看起來矛盾——實際上不矛盾,兩邊講的是不同的攻擊路徑:
verify_payload 在 license_center 裡只有兩個呼叫者—— ① 維運自己在終端機跑 lc verify <檔案路徑> 的 CLI(cli/main.py:226), ② unwrap_license_content,而它讀的是簽發站自己 DB 裡自己簽出來的 license_payload。 我 grep 過所有 web view,沒有任何一支 HTTP 端點會收外部照檔(internal_api.py 只 import sign_payload)。 沒有攻擊者可控的輸入源,所以在 LC 這一側不成立。三位驗證者的否決理由我都讀過,三份都明確說「sink 是真的、順序也是真的,但找不到 source」, 並各自列出他們追過的呼叫鏈——這是正確的否決,不是漏判。
不過要注意:engine.py 這支檔案在 LC 與 jedi-license-runtime 兩邊是同源實作。 CM-1572 修的時候要確認兩邊都修到,不要只修產品側。
建議開一張卡,把 F1 + F2 一起修(同一個子系統、同一個假設鏈、分開修容易只修一半):
開通序號憑證強度與 log 洩漏
new_activation_code改secrets.token_hex(16)(128 bits)- 未兌換序號加到期時間
activation_code_digest改印 keyed hash 或改印license_id,不留明文前綴- 補真實長度的守門測試(餵 8 字元與 32 字元各驗一次)
- 順帶確認
reissue_license複製序號到新列時,activated_at的繼承是否符合預期
與既有卡的關係:
core/logging.py)——這一條必須被涵蓋, 不論是加進 CM-1583 還是另開。優先序判準(依產品時程脈絡:第一版是落地版): 這兩條落地版打得到——客戶的 BE 會打開通端點,而 log 洩漏在任何部署形態下都成立。 建議綁落地版出貨前。
掃描原始產物在 license_center repo(有自己的 .gitignore,不入版控):
license_center/CLAUDE-SECURITY-20260906-051752/
├── CLAUDE-SECURITY-RESULTS.md # 英文完整報告
├── CLAUDE-SECURITY-RESULTS.jsonl # CI gate 用
├── CLAUDE-SECURITY-RESULTS.sarif # IDE/dashboard 用
└── CLAUDE-SECURITY-REVISION-6c1d9160380c.json # 版本與驗證戳記
web/views/activation.py 的三個洞給出一致判定,等於免費得到一次交叉驗證。