---
title: L1 掃描結果：License 驗章核心（密碼學層）
---

# L1 掃描結果：License 驗章核心（密碼學層）

> **掃描日期**：2026-09-06
> **掃描版本**：jedi-python-package `feature/FR-075` @ `67cb075941bd6fd57f1e05a76bc56d5fed984c70`（乾淨，無未 commit 變更）
> **工具**：Claude Code `claude-security` plugin v0.10.2.3（`claude-security:scan` workflow）
> **範圍**：`jedi-license-runtime/jedi_license_runtime/common`，10 個受版控檔案（啟動前用 `git ls-files` 驗過，與卡片數字吻合）
> **effort**：`low`，**focus**：`attack-surface`
> **模型**：主 session Opus 5 (1M context)，研究員繼承
> **狀態**：✅ **流程完整跑完，驗證面板 `verification.status: verified`**（讀 `CLAUDE-SECURITY-REVISION-67cb075941bd.json` 的 `verification` 欄確認，非自述）
> **對應卡片**：CM-1567（母卡 CM-1566）

## 一句話結論

**範圍內找到 1 條 HIGH，屬實**：任何登入使用者都能用一個約 1.4MB 的請求，讓後端在驗章之前先配置 1GB 記憶體，把 API 打掛。另外 2 條（MEDIUM／LOW）是研究員讀出範圍外的檔案找到的，事實成立但不屬本棒收穫。

## 執行概況

| 項目 | 數字 |
|---|---|
| 研究員派出 / 回報 | 2 / 2（無 stalled，無重試）|
| 候選發現 | 4 條（去重後仍 4）|
| 面板票數 | 12 票（4 條 × 3 票，無缺票）|
| 面板存活 | 3 條（F4 被 3:0 否決）|
| 總耗時 | 約 55 分鐘（3,288 秒）|
| agent 總數 | 14（全數完成，0 失敗、0 空回報）|
| token 消耗 | 約 129 萬 |

**與 FR-075 的對照**：S4～S7 用 Sonnet 5（200K）跑 40 檔以上四棒全滅、共燒 29.6 小時零產出。本棒改用 Opus 5 (1M) ＋ 10 檔範圍，**55 分鐘一次跑完、零 stalled**。「Opus 1M ＋ 小範圍」這個組合驗證成立，L2～L4 照此設定跑。

## ⚠️ 研究員再次越界讀檔（與 FR-075 S7 同一模式）

面板確認的 3 條裡，**只有 1 條在指派範圍內**：

| ID | 標題 | 檔案 | 在範圍內？ |
|---|---|---|---|
| **F1** | 驗章前無界解壓縮（解壓炸彈） | `jedi-license-runtime/.../common/engine.py:114` | ✅ **是** |
| F2 | GitHub/GitLab PAT 進版控與已發佈 wheel | `jedi-issue/dist/*.whl` | ❌ 否（jedi-issue） |
| F3 | Turnstile 退回永遠通過的測試金鑰 | `jedi-iam/.../turnstile/verifier.py:38` | ❌ 否（jedi-iam） |

F2／F3 事實面我都核對過、確實成立，但屬於別的套件。**面板不驗證「發現是否在指派範圍內」**（它只驗「這條成不成立」），所以越界不會被攔下——這是 plugin 的已知行為，L2～L4 收報告時要同樣自己挑掉範圍外的。

F3 另有一個處置參考價值：`verifier.py` 的 docstring 顯示作者**已經意識到**「驗證永遠通過是不會報錯的病」，卻仍保留 fallback。那是有意識的取捨（開發便利 vs 失效靜默），不是無知疏漏，裁決時值得納入考量。

---

## F1（範圍內唯一發現）— 驗章前的無界解壓縮：解壓炸彈打掛 API

**嚴重度**：HIGH · **信心**：HIGH · **面板**：3/3 判定成立 · **CWE-409**
**位置**：`jedi-license-runtime/jedi_license_runtime/common/engine.py:114`（`_unpack_payload`）

### 問題在說什麼（白話）

License 有兩種信封格式。v2 格式的照，內容是**壓縮過再 base64** 的，所以驗章之前必須先解壓縮還原成原文。程式碼就照這個順序做：先解壓（engine.py:179），再驗簽章（engine.py:213）。

問題是**解壓縮沒有設上限**。攻擊者送一個「壓縮得極小、解開極大」的假 payload（解壓炸彈），後端會**先老老實實把它全部解開**、吃掉幾 GB 記憶體，然後才走到驗章那一步發現簽章是假的——但這時記憶體已經爆了，process 被 OOM 殺掉。簽章根本來不及保護任何東西。

### 實際影響

我實測過放大倍率（不是照抄工具宣稱）：

```
1GB 的零 → 壓縮後 1,043,644 bytes → base64 後 1,391,528 bytes
放大倍率：772 倍（相對於請求 body 大小）
推算：10MB 的請求 body → 展開約 7.5 GB
```

也就是**一個 1.4MB 的 HTTP 請求就能讓後端配置 1GB**。配置發生在請求執行緒內、同步進行，所以：

- 一般部署：gunicorn worker 被 OOM 殺掉
- **落地版（單容器 stack）**：整個 `guidant-api` 容器被殺 —— 全產品停擺

而且這個請求**極廉價可重複**，攻擊者連續送就能讓服務持續起不來。這是純可用性攻擊，不影響機密性與完整性（簽章該擋的它還是擋得住，只是來不及擋）。

### 觸發前提（三項我都逐一核實過）

1. **攻擊者只需要一個有效 JWT** —— 端點 `/api/1.0/license/activation/upload` 只掛 `jwt_required()`，無管理員角色、無 capability 檢查、無需已有授權。這是**刻意的設計**：程式碼註解寫得很清楚，「無照租戶唯一的自救路徑，套上管理員守門的話，還沒開通的租戶就再也開通不了」。所以這不是漏掛守門，而是防死鎖設計必然敞開的一扇門——正因為敞開，門後的東西更需要自我保護。
2. **上游沒有 body 大小限制** —— BE 全 codebase 無 `MAX_CONTENT_LENGTH`（grep 確認），落地版 nginx 是 `client_max_body_size 0`（FR-065 design.md:218，刻意設的，為了大檔上傳與長工時匯出）。兩層都不擋。
3. **信封要帶 `format_version: 2`** —— 這完全由攻擊者控制，`request.get_json()` 之後直接 `payload.get("license")` 丟進 `verify_license`，中間沒有任何 schema 驗證。

補充：唯讀 gate 對 `/license/` 前綴有整段豁免，所以**連被鎖定的租戶也送得出這個請求**。

### 建議修法

**不能靠「把驗章移到解壓之前」解決** —— 簽章簽的正是解壓後的 canonical bytes，順序不能對調。必須加上限：

```python
def _unpack_payload(packed: str) -> bytes:
    raw = base64.b64decode(packed)
    d = zlib.decompressobj()
    out = d.decompress(raw, MAX_PAYLOAD_BYTES)   # 有界解壓
    if d.unconsumed_tail:                         # 還有剩 = 超過上限
        raise ValueError("payload 超過大小上限")
    return out
```

`MAX_PAYLOAD_BYTES` 設幾百 KB 即可（一張照的 payload 是很小的 JSON）。另建議兩道縱深：解碼前先擋掉過長的 `payload` 字串，以及在 host Flask app 設 `MAX_CONTENT_LENGTH`，讓無上限的 body 根本進不到應用層。

### 額外發現（研究員沒報，我在核對時查到的）

同款無界解壓在 `jedi-integrity` 還有兩處：

- `jedi-integrity/harness/dev_app.py:103`
- `jedi-integrity/tests/conftest.py:84`

兩處都不是產品路徑（開發 harness 與測試），**不構成線上風險**，但顯示這個 pattern 在簽發／驗證鏈是**複製擴散**的。修 F1 時建議一併處理，避免日後有人照抄舊寫法再種一次。

---

## F2（範圍外）— GitHub/GitLab PAT 進了版控歷史與已發佈的 wheel

**嚴重度**：MEDIUM · **信心**：MEDIUM · **面板**：2/3 判定成立 · **CWE-798**
**位置**：`jedi-issue/dist/jedi_issue-0.0.15-py3-none-any.whl`（原始碼路徑 `jedi-issue/jedi_issue/.env` 已於 d6a8d09 刪除）

**事實核對**：屬實。我用 `unzip -l` 確認 `jedi_issue/.env`（292 bytes）**至今仍實體存在於** 0.0.14 與 0.0.15 兩個 wheel 內。原始碼路徑在 2026-07-25（d6a8d09）刪掉了，但那不會清掉 git 歷史，也不會清掉已經推上內部 Nexus 的成品。

`pyproject.toml` 把 `jedi_issue/` 整包打包，所以 `.env` 被建進了發佈物。任何人只要 `git show d6a8d09^:jedi-issue/jedi_issue/.env`，或從 Nexus 拉 0.0.14／0.0.15 解開，都拿得到那兩把 token。

**注意**：d6a8d09 的 commit message 宣稱 token 已撤銷，但**那是作者宣稱、無法從程式碼驗證**。

**建議**：① 到 GitHub／GitLab 後台**實際確認**兩把 PAT 已撤銷（不要只信 commit message）；② 從 Nexus 刪掉 jedi_issue 0.0.14／0.0.15，並清掉本地 `dist/` 殘留；③ 歷史 blob 用 filter-repo／BFG 清掉，或接受歷史、把那兩把 token 當永久燒毀處理；④ 往後 `.env` 不進打包路徑，加 pre-commit secret scanner。

> 本報告不記錄 token 實際值。

## F3（範圍外）— Turnstile 沒設密鑰時退回「永遠通過」的測試金鑰

**嚴重度**：LOW · **信心**：MEDIUM · **面板**：2/3 判定成立 · **CWE-1188**
**位置**：`jedi-iam/jedi_iam/turnstile/verifier.py:38`（`_resolve_secret_key`）

**事實核對**：屬實。`TURNSTILE_SECRET_KEY` 為空／不存在／JSON 解析出空值時，回傳 Cloudflare 官方文件記載的「永遠成功」測試金鑰，於是 `verify()` 對任何 token 都拿到 `success: true`。登入的人機驗證等於關閉，**且無錯誤、無 log**，從外部看不出這個部署的 Turnstile 是活的還是死的。

影響有限而非致命：帳號鎖定機制（`MAX_LOCK_COUNT` / `USER_LOCK_TIME`）仍在，單一帳號的暴力猜測還是有上限。攻擊者實際得到的是**不受節流的帳號列舉與跨帳號橫向噴灑**。

**值得注意的取捨**：該函式 docstring 明白寫著改成「每次呼叫重讀」的理由是——模組級快取會導致「開機後才接線設定讀取器」時快取到測試 key 並永久沿用，「症狀是驗證永遠通過這種不會報錯的病」。也就是**作者已經看見這個病，卻選擇保留 fallback**。要不要改屬產品決策，不是純技術疏漏。

**建議**：密鑰解析為空時 fail closed（拒絕驗證），要關 CAPTCHA 就走既有的 enable/disable 開關明示關閉。若要保留開發便利預設值，綁在 debug flag 上並在啟動時印警告。

---

## 覆蓋率與「零發現」的分界

- 範圍 10 檔全數在研究員讀取範圍內，`collapsed: null`（未退化成小範圍簡化流程），跑的是完整管線
- 無元件被丟棄或跳過、無 bucket 被裁剪、無對抗階段損耗、無因上限而未驗證的候選
- `focus` 有設，所以另跑了一輪專門的密鑰掃描（`sweep:secrets`）

**關於卡片提的六個重點，哪些「讀過但沒找到問題」**：研究員的候選集中在 `engine.py` 的解壓路徑，卡片點名的其餘幾項——v1/v2 降級、`payload_type` 型別混淆、canonical JSON 兩端一致性、時鐘回撥浮水印、機器指紋 fallback、三環境硬編公鑰——**都沒有產生候選發現**。

這裡要誠實區分：這些檔案確實在研究員的讀取範圍內（10 檔全在 scope，且 `engine.py`／`machine_fingerprint.py`／`public_keys.py` 正是被讀出 F1 的同一批檔案），所以**不是「根本沒讀到」**。但「沒回報候選」也不等於「已被逐項排除」——`low` effort 只派一位研究員通掃，不保證對每個假設都做過針對性推演。**三環境硬編公鑰**（DEV 私鑰簽的照 POC 也認）尤其值得留意：它在程式碼裡是明擺著的事實，研究員沒把它當成發現，可能是判定為刻意設計，也可能只是沒往那想。這一項建議由決策者直接裁定，不要等下一輪掃描。

## 交付物

掃描原始產物在 jedi-python-package repo 的 `CLAUDE-SECURITY-20260906-031103/`（該目錄有自己的 `.gitignore`，預設不進 commit）：

- `CLAUDE-SECURITY-RESULTS.md` — 工具產出的英文報告
- `CLAUDE-SECURITY-RESULTS.jsonl` — CI gate 用
- `CLAUDE-SECURITY-RESULTS.sarif` — code scanning dashboard 用
- `CLAUDE-SECURITY-REVISION-67cb075941bd.json` — 版本與驗證戳記

## 建議後續

| 項目 | 建議 |
|---|---|
| **F1** | 開修正卡（範圍內唯一發現，HIGH，修法明確且小），一併處理 jedi-integrity 兩處同款寫法 |
| F2 | 轉給 jedi-issue 負責人；**先做的是去後台確認 PAT 撤銷狀態**，這比改程式碼急 |
| F3 | 屬 jedi-iam，與 FR-075 S3（MFA/Turnstile）同範圍，併入該案裁決 |
| 三環境硬編公鑰 | 掃描沒回報，但事實明擺著，建議決策者直接裁定是設計還是疏漏 |
| L2～L4 | 照本棒設定跑（Opus 1M ＋ effort low ＋ focus attack-surface）；收報告時**自己挑掉範圍外的發現** |
