---
title: 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/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 已報** |

---

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

**位置**：`src/license_center/modules/licensing/service.py:43`，`new_activation_code`
**嚴重度**：HIGH　**信心**：高　**CWE-331**　**面板**：3-0

#### 白話說明

客戶要啟用產品時，公司給他一組「開通序號」，他的後端拿這組序號打簽發站，
換回一張綁定他機器的授權照。這支端點**刻意不做 token 認證**——設計上，
序號本身就是密碼（程式碼註解寫得很清楚：「本端點無 token 認證——憑證就是開通序號本身」）。

問題是這個「密碼」只有 8 個十六進位字：

```python
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.toml` 與 `src/` 下所有 `.py`，**沒有任何 rate limiter 套件或實作**
- 部署文件的 systemd unit 是 `gunicorn -b 0.0.0.0:5062`，前面沒有反向代理

#### 猜中之後拿到什麼

回應的 body 直接就是一張**簽好章的授權照**，綁在攻擊者自己的機器指紋上。
照裡面還帶著 `customer_code`、`order_no`、`issued_to`、`tenant_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:46` 用 `secrets.token_urlsafe(32)` 產 API token、
只存 sha256 hash、DB 加 UNIQUE。**這個 repo 知道怎麼做對**，開通序號沒套用同一套標準。

---

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

**位置**：`src/license_center/core/logging.py:127`，`activation_code_digest`
**嚴重度**：MEDIUM（工具判定）／**我認為應升 HIGH**　**信心**：高　**CWE-532**　**面板**：3-0

#### 白話說明

程式碼裡有一支專門的函式，職責就是「把開通序號遮起來再寫 log」——
它的 docstring 白紙黑字寫著「未使用的開通序號等同憑證，同樣屬安全紅線不入 log 明文」。

但實作是這樣：

```python
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 是 `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`（授權已過期）——
  **這兩條記的是「還沒被兌換」的序號**，也就是還能用的活憑證
- 唯一的 logging filter 是 `RequestIdFilter`（`logging.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` 確認 `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 裡的舊序號都可能重新生效。

---

### 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 涵蓋，不另開卡。**

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

## 遭面板否決的候選（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 修的時候要確認兩邊都修到**，不要只修產品側。

## 給首腦的修正卡建議

**建議開一張卡，把 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` 的繼承是否符合預期

**與既有卡的關係**：
- **CM-1583**（L4 開的「開通端點防猜失效」）已含「序號熵拉高」一項——
  **本棒 F1 是對那一項的獨立佐證與細節補完**，可以合併進 CM-1583 而不必另開，
  由首腦決定要合併還是拆開（合併的好處是三道防線一起上，不會只修一半）。
- **F2 是全新的**，L4 沒報過（L4 的範圍不含 `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   # 版本與驗證戳記
```

## 方法論備註

- **「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`
  的三個洞給出一致判定，等於免費得到一次交叉驗證。
