L3 掃描結果:License Center 簽發核心與模組

L3 掃描結果:License Center 簽發核心與模組

掃描日期:2026-09-06 掃描版本:license_center feature/FR-075 @ 6c1d9160380cf23b8bb70a522dca4c91506146f9(乾淨,無未 commit 變更) 工具:Claude Code claude-security plugin v0.10.2.3(claude-security:scan workflow) 範圍src/license_center/coresrc/license_center/modulessrc/license_center/cli,32 個受版控檔案(啟動前用 git ls-files 驗過,與卡片數字吻合) effortlowfocusattack-surface 模型:主 session Opus 5 (1M context),研究員繼承 狀態:✅ 流程完整跑完,驗證面板 verification.status: verified(讀 CLAUDE-SECURITY-REVISION-6c1d9160380c.jsonverification 欄確認,非自述) 對應卡片:CM-1569(母卡 CM-1566)

§1

一句話結論

範圍內找到 2 條(1 HIGH、1 MEDIUM),全部屬實。

兩條講的是同一件事的兩面:開通序號只有 8 個字、而且它整串會被寫進 log

開通序號是「客戶拿它去換一張簽好的授權照」的唯一憑證——那支端點刻意不做 token 認證, 序號本身就是密碼。但這個密碼只有 8 個十六進位字(約 43 億種組合), 而防猜機制(L4 已報)拿「被猜的那個序號」當計數 key,所以怎麼猜都不會被鎖。 更糟的是,專門負責「把序號遮起來再寫 log」的那支函式,遮罩寫成保留前 8 碼—— 而序號剛好就是 8 碼,等於整串原封不動印進 journal。

另外 3 條(F3/F4/F5)是研究員讀出範圍外的 web/ 層找到的,L4 那一棒已經全部報過, 此處僅列出並標示重複,不重複開卡。

§2

執行概況

項目 數值
開始 / 結束 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 一致否決)

唯一一次 stallsweep:secrets 這支 agent 在 2056 秒無進度後被判 stalled, 自動重試第 1 次即成功。這與 FR-075 用 Sonnet 時「反覆 stall 到全滅」是不同性質—— 那是 context 撐不住,這只是單支慢,重試就過。

§3

掃了什麼、沒掃什麼

掃了(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,一支研究員通掃 + 一次補漏掃。 completenessCheckOutcomenot-applicable——這是指定範圍的掃描, 它的「完整」定義就是那 32 個檔,不是整棵樹。

沒有任何東西被 cap 截斷:無 pruned bucket、無未審候選、無 adversarial casualty、無跳過的元件。

沒有執行任何程式碼:沒跑測試、沒發請求、沒實際打端點。全部發現都來自讀原始碼; 我的復核也是開檔比對,不是實測。

§4

發現清單

# 嚴重度 位置 一句話 面板 範圍 復核
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 已報

F1 — 開通序號的熵太低(暴力猜得到別人的授權照)

位置src/license_center/modules/licensing/service.py:43new_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

為什麼 43 億不算安全

因為攻擊者不需要猜中「某一個特定序號」,只要猜中任何一個還沒被用掉的序號就有收穫。 簽發站每簽一張照就產一組序號(issue_license 無條件產,連 SaaS 那種永遠不會被開通的也產), 所以未使用序號的池子只會愈長愈大。有 N 組活序號時,期望要送 2³²/N 個請求。

而且完全沒有東西擋這些請求

  • 唯一的防猜機制(F3)拿被猜的序號當計數 key,換一個序號就是全新計數,永遠鎖不到
  • 我 grep 過 pyproject.tomlsrc/ 下所有 .py沒有任何 rate limiter 套件或實作
  • 部署文件的 systemd unit 是 gunicorn -b 0.0.0.0:5062,前面沒有反向代理

猜中之後拿到什麼

回應的 body 直接就是一張簽好章的授權照,綁在攻擊者自己的機器指紋上。 照裡面還帶著 customer_codeorder_noissued_totenant_id——等於順便洩漏客戶資料。 同時 activated_at 被寫上,原本那個付錢的客戶就再也開通不了(他去打會拿到 409「此開通序號已被使用」)。

觸發前提

  • 打得到 /api/activation/activate(依設計就是要給客戶後端打的)
  • 至少有一組已簽發、未過期、還沒被兌換的序號

建議修法

  1. 序號改用 secrets.token_hex(16)(128 bits)以上,並全程當機密處理
  2. 未兌換的序號給一個到期時間——現在是永久有效,池子只增不減
  3. 端點加 per-IP 與全域 rate limit(這與 F3 是同一道防線的兩面)

三項要一起做。單獨拉高熵仍讓 F3 的無限猜測存在(只是不划算了); 單獨修 F3 而序號還是 8 碼,則一旦有人繞過 IP 限制(例如殭屍網路)就還是打得穿。

我的復核

屬實。 逐項開檔核對:

  1. service.py:43 確為 uuid.uuid4().hex[:8].upper(),32 bits 無誤
  2. service.py:72 確認 issue_license 無條件呼叫 new_activation_code(), 不論 license type/deployment mode——SaaS 照也會產一組永遠不會被用的序號
  3. web/views/activation.py:82-172 確認端點無任何認證 decorator, 對照 internal_api.py:78@bp.before_request _authenticate()(走 X-API-Token 白名單)—— 一個有守門一個沒有,是刻意的設計差異
  4. grep -rn -E "limiter|Limiter|ratelimit" pyproject.toml $(find src/license_center -name '*.py')零筆
  5. activation.py:172 確認成功時 return jsonify({"license": result.signed}), 200, 照直接回在 body
  6. service.py:250-291 確認 activate_license 只用序號查(get_by_activation_code), 不比對租戶、不比對訂單、不比對任何第二因素,然後直接用私鑰簽

一個對比值得寫下來:同 repo 的 api_tokens.py:46secrets.token_urlsafe(32) 產 API token、 只存 sha256 hash、DB 加 UNIQUE。這個 repo 知道怎麼做對,開通序號沒套用同一套標準。


F2 — 「遮蔽開通序號」的函式其實整串印出來

位置src/license_center/core/logging.py:127activation_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, 而所有呼叫端都以為自己已經遮過了。

這條與 F1 是同一個錯的兩面

兩個地方各自看都「有道理」:8 碼夠短所以取全部沒關係(logging 的假設)、 序號夠亂所以不必更長(licensing 的假設)。兩個假設互相依賴、卻沒有任何一處把它們對起來。 這正是為什麼修 F1(把序號拉長到 32 碼)會意外地讓 F2 從「全洩」變成「洩前 8 碼」—— 但那是巧合不是修正,兩條要分別修。

洩到哪裡、洩多少

  • 預設 log level 是 INFOcore/config.py:42),而成功開通那條就是 logger.infoactivation.py:169),所以正常運作就在洩
  • 呼叫點涵蓋所有路徑:views/activation.py:58,118,127,133,142,169(免認證 API)、 views/issuance.py:251(後台手動受理)、cli/main.py:305(離線開通)
  • 特別要注意 activation.py:127(找不到序號)與 :142(授權已過期)—— 這兩條記的是「還沒被兌換」的序號,也就是還能用的活憑證
  • 唯一的 logging filter 是 RequestIdFilterlogging.py:53-63),它只加 request_id,不做任何遮蔽

拿到 log 的人(維運、log 轉送管線、工單附件、log 備份)等於拿到可直接兌換的憑證。

為什麼我認為應該是 HIGH 而不是 MEDIUM

工具給 MEDIUM 的理由是「需要先有 log 存取權」。這個門檻在本專案的實際脈絡下很低:

  1. journal 在本 repo 自己的規範裡就被定義為半公開——CLAUDE.md 明文把「完整開通序號」列為 不入 log 的紅線,代表寫規範的人本來就假設 log 會被廣泛閱讀
  2. log 會被貼進工單。這不是假設性風險,是日常維運行為
  3. 它讓 F1 從「要猜 43 億次」變成「直接讀」——兩條疊起來,攻擊成本從暴力破解降到零

不過我保留工具的 MEDIUM 標記,因為嚴重度分級的最終判準在決策者, 而「需要一個立足點」確實是真的。修正優先序上我建議與 F1 同一張卡、同時修。

還有一個現存的坑:守門測試是空的

tests/test_logging_setup.py:131 有一個測試要驗這支函式會遮蔽, 但它餵的字串是 18 個字——永遠不會踩到「序號剛好等於前綴長度」這個真實情況。 所以這個洞從寫下來到現在,測試一直是綠的。

建議修法

  1. 只印 keyed hash,不留明文前綴hmac_sha256(secret, code)[:8]
  2. 或者更簡單:改印 license_id。維運要的是「把同一組序號的 log 串起來」, 而每個呼叫點手上都有 license_id,它不是憑證、天生就適合當關聯 key
  3. 補一個真的測試:餵 8 字元序號,斷言輸出不含該序號

我的復核

屬實。 核對紀錄:

  1. core/logging.py:117-127 逐行讀過,return f"{activation_code[:8]}...{digest}" 一字不差
  2. service.py:43 對照確認序號長度恰為 8——fingerprint_digest(同檔 106-114)取前 12 碼 是合理的(機器指紋長得多),問題只出在序號這一支
  3. core/config.py:42 確認 DEFAULT_LOG_LEVEL = "INFO"
  4. activation.py 五個呼叫點逐一開過,其中 :127:142 記的確實是未兌換序號
  5. logging.py:53-63 確認 RequestIdFilterrecord.request_id = ...,無遮蔽邏輯

另外自己查證的一點(研究員與面板都提到、我實際追過): reissue_licenseservice.py:225)補發時會把舊序號複製到新列, 而新列的 activated_at 是 None(沒有寫入)。加上 get_by_activation_coderepository.py:122-134)取 created_at DESC 的最新一筆—— 這代表一組曾經被用掉、且曾經寫進 log 的序號,在補發後會復活成可用狀態。 這讓 F2 的影響不限於「還沒兌換的序號」,連歷史 log 裡的舊序號都可能重新生效。


F3/F4/F5 — 越界發現(L4 已報,此處不重複開卡)

這三條研究員讀出了指派範圍,落在 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 涵蓋,不另開卡。

值得記一筆的是:兩棒獨立掃描、獨立面板,對同樣三個洞給出一致結論, 這算是對掃描結果可信度的一次側面驗證。

§5

遭面板否決的候選(3 條)

候選 位置 否決票 否決理由(我複核後同意/不同意)
驗章前的無界解壓縮(解壓炸彈) 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 的判定不衝突

這條值得特別說明,因為 L1(jedi-license-runtime)把同一個議題判成 HIGH 並開了 CM-1572, 而這裡面板判 FALSE_POSITIVE,看起來矛盾——實際上不矛盾,兩邊講的是不同的攻擊路徑:

  • L1 的場景:主產品 BE 有端點會收外部上傳的照,攻擊者可以餵一個 1.4MB 的壓縮炸彈進去, 驗章前就先解成 1GB。攻擊者控制輸入,所以成立。
  • LC 這邊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 修的時候要確認兩邊都修到,不要只修產品側。

§6

給首腦的修正卡建議

建議開一張卡,把 F1 + F2 一起修(同一個子系統、同一個假設鏈、分開修容易只修一半):

開通序號憑證強度與 log 洩漏

  • new_activation_codesecrets.token_hex(16)(128 bits)
  • 未兌換序號加到期時間
  • activation_code_digest 改印 keyed hash 或改印 license_id,不留明文前綴
  • 補真實長度的守門測試(餵 8 字元與 32 字元各驗一次)
  • 順帶確認 reissue_license 複製序號到新列時,activated_at 的繼承是否符合預期

與既有卡的關係

  • CM-1583(L4 開的「開通端點防猜失效」)已含「序號熵拉高」一項—— 本棒 F1 是對那一項的獨立佐證與細節補完,可以合併進 CM-1583 而不必另開, 由首腦決定要合併還是拆開(合併的好處是三道防線一起上,不會只修一半)。
  • F2 是全新的,L4 沒報過(L4 的範圍不含 core/logging.py)——這一條必須被涵蓋, 不論是加進 CM-1583 還是另開。

優先序判準(依產品時程脈絡:第一版是落地版): 這兩條落地版打得到——客戶的 BE 會打開通端點,而 log 洩漏在任何部署形態下都成立。 建議綁落地版出貨前

§7

產物位置

掃描原始產物在 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   # 版本與驗證戳記
§8

方法論備註

  • 「Opus 1M + 小範圍」再次成立:32 檔 2 小時 9 分一次跑完,唯一一次 stall 重試即過。 四棒下來(L1 10 檔 55 分、L2 33 檔 90 分、L4 43 檔 110 分、L3 32 檔 129 分) 沒有任何一棒因 context 爆掉而全滅,對照 FR-075 用 Sonnet 的四棒全滅。
  • 越界讀檔第三次出現(FR-075 S7、FR-076 L1、本棒)。這已經是穩定行為不是偶發, 收報告時把「範圍內/越界」當成必填欄位是必要的流程防護。
  • 兩棒重疊掃到同一批洞是好事不是浪費:L3 與 L4 對 web/views/activation.py 的三個洞給出一致判定,等於免費得到一次交叉驗證。