# FR-084.T1 掃描報告：防竄改機制「套件本體」（jedi-integrity）

- **卡片**：[CM-1645](https://app.notion.com/p/FR-084-T1-21-jedi-monorepo-Opus-1M-low-3d7346da4cd081e3bf25df1377d6031f)（母卡 [CM-1644](https://app.notion.com/p/FR-084-jedi-integrity-3d7346da4cd081c48d2bf55cf50450a2)）
- **掃描範圍**：`jedi-integrity` 套件的 `jedi_integrity/` ＋ `harness/`，共 21 檔約 3,250 行
- **版本**：commit `8aa6f060`（jedi monorepo，branch `feature/FR-075`）
- **日期**：2026-09-10

---

## 1. 🔴 一句話結論

**這套防竄改機制擋得住「隨手改個檔案」，但擋不住「已經拿到主機管理權限、而且知道它怎麼運作」的人——目前找到四條繞過路徑，其中兩條只要一個指令就能讓被鎖住的機器重新開起來。**

要強調的是：這四條**都不是程式寫錯**，每一條在程式碼裡都有一段註解說明「為什麼這樣設計是合理的」。問題在於那些理由是在「攻擊者只是個手滑的維運人員」的假設下成立的，而這套機制真正要防的是「刻意想破解產品保護的人」。

---

## 2. 這一棒在檢查什麼（白話）

我們的落地版產品（裝在客戶自己機房裡的那種）有一套「防竄改機制」，運作方式像這樣：

1. 出貨前，原廠把產品裡每一個檔案算出一組**指紋**（就像每個檔案的身分證號碼），列成一份清單，再用原廠的私章簽名——這份簽了名的清單叫 **manifest**。
2. 產品每次開機時，逐檔重算指紋、跟清單比對。只要有一個對不上，就**拒絕啟動**並把機器**鎖定**。
3. 服務跑起來之後，大約每 4 小時再抽查一次。
4. 被鎖定的機器，只有原廠簽發的**一次性解鎖檔**能解開。

這一棒要回答的是：**這套保護能不能被繞過、被關掉，或者反過來被拿來搗亂**（例如讓正常客戶的機器莫名被鎖死、或鎖了之後解不開）。

**只找問題、不修問題**——所有修法都只寫建議，這一棒不動任何程式碼。

### 一個重要前提

這套機制的威脅模型（假想敵）是「**攻擊者已經能改動這台機器上的檔案**」——因為他要竄改產品，本來就得先有這個能力。所以底下每一條發現，「需要主機管理權限」都**不能當成減輕理由**。真正要問的是：**有了主機權限之後，繞過這套保護還要不要額外成本？** 如果答案是「不用，再多打一個指令就好」，那這道保護對他而言等於不存在。

---

## 3. 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| **T1-1** | 只要刪掉一個標記檔，被鎖住的機器就能重新開起來 | 鎖定形同虛設；文件宣稱的「兩道鎖」實際只有一道 | 主機管理權限（**不需額外成本，就是一行 `rm`**） | `startup_gate.py:206` | **HIGH** |
| **T1-2** | 開機檢查不涵蓋負責「驗章」的那個函式庫，換掉它就能讓所有檢查永遠通過 | 整套保護完全失效，且產品自己不會察覺 | 主機管理權限＋知道要換哪個檔 | `config.py:49`＋`build_release.sh:365` | **HIGH** |
| **T1-3** | 一個環境變數就能把「要檢查哪個目錄」指到別處 | 檢查一個空目錄＝零個檔案要比對＝必定通過 | 能改動服務的啟動環境設定 | `resource_path.py:31-39` | **HIGH** |
| **T1-4** | 把「用過的解鎖檔紀錄」弄壞，同一張解鎖檔就能再用一次 | 一次性憑證變成可重複使用 | 主機管理權限＋手上有一張用過的解鎖檔 | `unlock.py:83-100` | **MEDIUM** |
| **T1-5** | 鎖定畫面對外開放，任何人都能拿到機器指紋與事件編號 | 洩漏申請解鎖所需的兩項資訊；另可被塞爆連線 | 連得到那個網路位址 | `config.py:93`、`lockdown.py:182` | **LOW** |
| **T1-6** | 竄改事件回報會把伺服器 log 最後 50 行往外送 | log 尾段可能夾帶密碼或金鑰，隨事件送到原廠伺服器 | 需先觸發竄改事件 | `forensic.py:108-129` | **LOW** |

**沒有發現的項目**：⑦終止流程對 PID 的信任、⑧解鎖檔效期與時鐘、⑨埋點退化、⑩開發用啟動殼——四項都查了，結論寫在第 5 節。

---

## 4. 每條發現的詳述

### T1-1（HIGH）只要刪掉一個標記檔，被鎖住的機器就能重新開起來

**這是什麼問題（白話）**
機器被判定「遭竄改」之後，這件事會記在兩個地方：一個是硬碟上的**標記檔**，一個是**資料庫**裡的一筆紀錄。產品文件與程式碼註解都說：「這兩個地方**任何一個**還在，就拒絕開機」——意思是攻擊者要把兩邊都洗掉才解得開，成本加倍。

**但實際的程式碼只看硬碟那個檔，從來不看資料庫。** 負責查資料庫的那支程式（`exists()`）寫好了，卻**全套件與宿主專案都沒有任何一行呼叫它**。

**出事會怎樣**
機器被鎖之後，有主機權限的人只要刪掉一個檔（`/opt/guidant/pki/.integrity-tamper`），重開服務就正常起來了。原本設計的「兩道鎖」實際上只有一道，而那一道就是一個普通檔案。文件承諾的成本加倍不存在。

**要先有什麼才打得到**
- 主機管理權限（能進到機器裡刪檔）——**但這正是威脅模型的起點**，攻擊者為了竄改產品本來就得先有這個能力
- **不需要任何額外成本**：不用破解、不用逆向、不用猜密碼，就是一行 `rm`
- 附帶條件：他還得把被改壞的檔案還原（否則開機時第二道檢查會再次鎖定）。但如果他的目的就是**移除鎖定紀錄、湮滅痕跡**，這一步反而正合他意

**在哪裡**
- `jedi_integrity/startup_gate.py:206` — `marker = tamper_marker.read_marker(config)`，這是開機流程唯一一次讀取竄改狀態，只讀檔案
- `jedi_integrity/infra/tamper_event_repo.py:96` — `exists()` 方法，**零呼叫者**（已 grep 套件與 `compliance-manager-be` 全樹確認；宿主只用到 `insert_if_absent`（`core/app_factory.py:347`）與 `mark_unlocked`（`core/scheduler.py:311`））
- 文件宣稱處：`jedi_integrity/__init__.py:11`、`tamper_marker.py:5-7`、`README.md:32`
- 程式碼自己也承認了：`app/service/tamper_sync_service.py:13-15` 寫「補同步是把『FS 被洗掉』這個洞的窗口縮到最小」——它知道有這個洞，只說窗口縮小了

**怎麼修**
在 `startup_gate.py:206` 那一段，讀完檔案標記後**再查一次資料庫**：呼叫 `TamperEventRepo.exists()`，任一為真就走拒啟流程。

技術障礙是「開機閘門跑在資料庫連線建立之前」——這正是當初只做檔案這一半的原因。三個可行方向：
1. **開機時額外開一條短連線**專門查這張表，查完就關（這張表刻意沒有多租戶隔離，不需要使用者身分脈絡，技術上可行）
2. 若堅持不在開機期碰資料庫，就**把文件改成與程式一致**，別再宣稱「任一存在即拒啟」——現在的落差會讓維護者誤以為有兩道鎖而不去補
3. 折衷：服務起來後的補同步發現「資料庫有紀錄但檔案標記不見了」時，**立刻重新寫回檔案標記並終止服務**（目前的補同步只單向把檔案寫進資料庫，不會反向補回）

**首腦核對註記**：工具的三位檢查員對這條投 1 比 2（一票認為是漏洞、兩票認為提出的攻擊手法被周邊程式碼駁回），未達成立門檻，所以工具的正式報告沒有列它。**我開檔逐行核對後判定成立，並提高到 HIGH**：兩位反對者的理由是「刪掉標記後，被改壞的檔案仍會在第二道檢查被抓到」——這對「想繼續使用被破解產品」的攻擊者成立，但對「想湮滅鎖定痕跡」的攻擊者不成立，而後者正是這套機制要留下的法律證據。文件與程式碼不符則是三票一致確認的事實。

---

### T1-2（HIGH）開機檢查不涵蓋負責「驗章」的函式庫，換掉它就能讓所有檢查永遠通過

**這是什麼問題（白話）**
產品出貨時，所有檔案被分成三層：
- **core**（我們自己寫的程式，編譯成機器碼）
- **resources**（範本、翻譯檔這類資料）
- **thirdparty**（別人寫的公開套件，原樣附帶）

**開機時只檢查前兩層**，第三層交給「4 小時一次的抽查」隨機抽 200 個檔。

問題是：**負責驗證原廠簽名的那個函式庫（`cryptography`）落在第三層**。也就是說，開機時完全不會檢查「驗章的工具本身有沒有被掉包」。

我循 build 腳本確認了這一點：`cryptography` 不在「不編譯清單」裡，但腳本會**自動把清單裡套件的相依也一併排除**，而 `pdfminer.six` 依賴 `cryptography`（`build_release.sh:365` 白紙黑字寫著這條相依鏈）。被排除的套件是**原樣複製**進產物、歸入 thirdparty 層。

**出事會怎樣**
攻擊者把 `cryptography` 裡負責驗簽名的那段換掉，讓它**無論收到什麼都回答「簽名正確」**，那麼：
- 他可以自己偽造一份 manifest（把被改過的檔案指紋寫成新的正確值），產品照單全收
- 開機檢查**完全失效**，而且產品自己毫無察覺，log 還會印「產物完整性驗證通過」
- 唯一還可能抓到的是 4 小時一次的抽查，但抽查也是用同一個被掉包的驗章工具，所以**它也一起失效**

換句話說：**這一步一旦成功，整套防竄改機制就結束了**，不是繞過一次，是永久關閉。

**要先有什麼才打得到**
- 主機管理權限
- 知道要換哪個檔（需要一點逆向工夫，但 `cryptography` 是公開套件、檔案結構完全公開，難度不高）
- **不需要**原廠私鑰、不需要破解密碼學

**在哪裡**
- `jedi_integrity/config.py:49` — `DEFAULT_BOOT_LAYERS = ("core", "resources")`，開機只驗這兩層
- `jedi_integrity/config.py:38` — `DEFAULT_SPOT_CHECK_THIRDPARTY_SAMPLE = 200`，抽查每輪只隨機抽 200 個
- `jedi_integrity/runtime_check.py:181-191` — `_sample_thirdparty()`，隨機抽樣的實作
- `scripts/build/build_release.sh:365`（BE repo）— 註解明列 `pdfminer.six → charset_normalizer, cryptography`
- `scripts/build/build_release.sh:370-406` — `resolve_excluded_closure()`，遞迴求相依封閉集合的實作
- `scripts/build/build_release.sh:895-906` — 把封閉集合寫進 thirdparty 清單
- 驗章的呼叫鏈：`common/integrity/adapters.py:33-38` → `jedi_license_runtime.common.engine.verify_payload` → `cryptography` 的 Ed25519

**怎麼修**
把「驗章相依的函式庫」從 thirdparty 提升到開機必驗的範圍。三個做法，建議第一個：

1. **在 `gen_integrity_manifest.py` 增設一個 `security-critical` 分層**，把 `cryptography`（及其 `.so`）歸進去，並加入 `DEFAULT_BOOT_LAYERS`。好處是不必把整個 thirdparty 都納入開機檢查（那會拖慢開機）。
2. 或在 `build_release.sh` 把 `cryptography` 從排除清單的封閉集合中**強制拉回編譯**，讓它變成 core 層的機器碼。要先確認它的原生 `.so` 能被 Nuitka 正確處理。
3. 最低限度：在 `runtime_check.py:_select_hotpath_targets()` 的優先清單（`config.py:41-45` 的 `hotpath_priority_prefixes`）加入 `cryptography/`，讓業務埋點每次都順帶驗它。這只是縮短空窗，不是根治。

**額外提醒**：即使不修，也該把 thirdparty 的抽查改成「**保證涵蓋安全關鍵檔**＋其餘隨機」，而不是純隨機。目前若 thirdparty 有數萬檔，每輪抽 200 個，抽中特定某檔的機率極低，平均要等非常久——而抽查本身已經被掉包的工具驗證，實際上抽到也沒用。

**首腦核對註記**：工具完全沒碰這條（**工具未報，runner 人工查證**）。分層判定是跨 repo 的事實（套件在 jedi monorepo、build 腳本在 BE repo），單一 repo 的掃描讀不到。我循 `resolve_excluded_closure()` 的實作與 `:365` 的註解確認相依鏈成立。**未實測**——沒有在任何環境實際掉包 `.so` 驗證，這是讀程式碼與 build 腳本推論的結果。建議下一棒在**本機**造一份假產物實測確認。

---

### T1-3（HIGH）一個環境變數就能把「要檢查哪個目錄」指到別處

**這是什麼問題（白話）**
產品用一個叫 `GUIDANT_RESOURCE_ROOT` 的環境變數決定「產品檔案放在哪個目錄」。防竄改檢查就是去那個目錄逐檔比對。

這個變數**沒有任何保護**：誰能改服務的啟動設定，誰就能把它指到任何地方。

**出事會怎樣**
把它指到一個**空目錄**，那裡沒有簽名清單檔，開機檢查會直接判定「清單不存在＝視同竄改」而拒啟——這一條是擋住的。

但指到一個**攻擊者自己準備好的目錄**（放一份他自己簽的 manifest 與對應檔案）就不一樣了。這條路要成功，仍然得先過 T1-2 那關（換掉驗章工具讓假簽名通過）。**兩條合起來用，就是一條完整的繞過路徑**：換掉驗章工具 → 自備一份假產物目錄 → 用環境變數指過去 → 開機一路綠燈。

單獨看它的價值在於：**它讓 T1-2 從「要偽造整份 manifest」降級成「隨便準備一個目錄就好」**，省掉攻擊者不少工。

**要先有什麼才打得到**
- 能改動服務的啟動環境設定（改 `docker-compose.yml`、`.env`、或 systemd 設定檔）——與主機管理權限實質等價
- 搭配 T1-2 才能發揮完整效果

**在哪裡**
- `common/util/resource_path.py:23` — `_ENV_OVERRIDE = "GUIDANT_RESOURCE_ROOT"`
- `common/util/resource_path.py:31-39` — `resource_root()`，**環境變數的優先序高於打包判定**（`if override: return ...` 在 `if is_packaged()` 之前）
- `docker/production/Dockerfile:245` — image 內釘死 `GUIDANT_RESOURCE_ROOT=/app`，但 `docker run --env-file` 或 compose 的 `environment:` 都會覆寫掉它
- Dockerfile:196-198 的註解說明釘死的理由是「讓路徑從哪來在部署現場一眼可見」——**是為了排錯方便，不是為了安全**

**怎麼修**
在 `resource_path.py:31` 的 `resource_root()` 裡，**打包模式下忽略這個環境變數**：

```python
def resource_root() -> Path:
    if is_packaged():
        return Path(sys.executable).resolve().parent   # 打包模式：只信 binary 位置
    override = os.getenv(_ENV_OVERRIDE)                # 覆寫只在源碼模式生效
    if override:
        return Path(override).resolve()
    return Path(__file__).resolve().parents[2]
```

這與套件既有的設計原則一致：`force_enforcement` 那個旗標就是「只能開不能關」（`config.py:78`，理由在 `manifest.py:80-81`）。`GUIDANT_RESOURCE_ROOT` 目前是「能把執法對象整個換掉」，比 `force_enforcement` 危險得多，卻沒有同等的保護。

**注意**：改之前要確認沒有任何正式部署情境真的依賴「打包模式下用環境變數換資源根目錄」。Dockerfile 的註解說打包模式下設不設結果相同，若屬實則此修改零影響。

**首腦核對註記**：工具未報（**工具未報，runner 人工查證**）——`resource_path.py` 在 BE repo，不在本次掃描範圍內。卡片「已知背景」已點名這個變數要追，我開檔確認了優先序問題。**未實測**。

---

### T1-4（MEDIUM）把「用過的解鎖檔紀錄」弄壞，同一張解鎖檔就能再用一次

**這是什麼問題（白話）**
機器被鎖定後，原廠會簽發一張**一次性解鎖檔**。為了確保它真的只能用一次，產品會把用過的那張的**流水號**（程式裡叫 nonce，就是一組一次性隨機碼）記在一個檔案裡（`used-nonces.json`）。下次有人拿解鎖檔來，先查這本帳，用過的就拒絕。

問題是：**這本帳讀不懂的時候，程式選擇當作「這本帳是空的」**——也就是「沒有任何解鎖檔用過」。程式註解自己說明了理由：「取寬鬆側……若取嚴格側，一次檔案損毀就會讓機器再也解不開，只能重灌。」

這個顧慮是合理的，但代價是：**攻擊者只要把這個檔案改成亂碼，用過的解鎖檔就復活了。**

**出事會怎樣**
拿過一張解鎖檔的客戶（或任何拿得到那張檔的人），可以無限次重複使用它——只要每次用之前先把 nonce 帳弄壞。

不過這條有三道額外關卡擋著，這也是它是 MEDIUM 而不是 HIGH 的原因：
1. 解鎖檔綁**機器指紋**——換一台機器就不能用
2. 解鎖檔綁**事件編號**——換一次鎖定事件就不能用
3. 解鎖檔有**有效期限**

所以實際能重放的情境很窄：**同一台機器、同一次鎖定事件、而且還在效期內**。這種情況下重複解鎖的實際收益不大（那次鎖定本來就該被這張檔解開）。

真正的問題是**它把一次性憑證的保證從「密碼學等級」降到「一個可寫檔案」等級**——防重放本來就是這道機制存在的唯一理由，而它可以被一行指令關掉。

**要先有什麼才打得到**
- 主機管理權限（能寫 `/opt/guidant/pki/used-nonces.json`）
- 手上有一張用過的、對應同一台機器與同一次事件、且還沒過期的解鎖檔

**在哪裡**
- `jedi_integrity/unlock.py:83-100` — `_read_used_nonces()`，`except (ValueError, TypeError): return []` 就是「讀不懂就當空的」
- `jedi_integrity/unlock.py:60` — `_MAX_USED_NONCES = 1000`
- `jedi_integrity/unlock.py:113-114` — 帳滿時 `nonces[-_MAX_USED_NONCES:]` 丟掉最舊的
- 對照組：`tamper_marker.py:102-126` 的 `read_marker()` 對同樣的「讀不懂」情況取**嚴格側**（回 `corrupted` 一律視同鎖定中）。同一個套件，兩個檔案，相反的失敗策略

**怎麼修**
把「讀不懂」與「檔案不存在」分開處理，而不是都回空清單：

- **檔案不存在** → 回空清單（正常，第一次解鎖）
- **檔案存在但解析失敗** → **不放行本次解鎖**，並印出明確訊息說明是「防重放紀錄毀損」而非「token 有問題」，指引客戶聯絡原廠

這樣就與 `tamper_marker.read_marker()` 的策略一致了。原本擔心的「一次損毀就再也解不開」有現成解法：原廠可以簽發**帶特殊旗標的救援解鎖檔**來覆蓋這個狀態，或指引客戶刪除該檔（刪除是「檔案不存在」，走寬鬆路徑）——**讓解法是原廠可控的動作，而不是預設放行**。

至於 `_MAX_USED_NONCES = 1000` 滿了丟最舊的：實務上一台機器解鎖次數以個位數計，這個上限遠超實務需求，且被丟掉的最舊 token 早已過期，**這一項不構成獨立風險**，不必修。

**首腦核對註記**：工具未報（**工具未報，runner 人工查證**）。**未實測**——沒有實際造一張解鎖檔測試重放，這是讀程式碼推論。判 MEDIUM 而非 HIGH 的理由如上：三道關卡把可利用的情境壓得很窄，但防重放機制本身確實可被停用。

---

### T1-5（LOW）鎖定畫面對外開放，任何人都能拿到機器指紋與事件編號

**這是什麼問題（白話）**
機器被鎖定後，產品會起一個小網頁伺服器，顯示「系統已鎖定」，並附上兩項資訊：**機器指紋**與**事件編號**。這是刻意設計的——客戶要拿這兩個值去跟原廠申請解鎖。

但這個小伺服器：
- 綁定在 `0.0.0.0`（意思是**接受來自任何網路位址的連線**，不只本機）
- 回應帶 `Access-Control-Allow-Origin: *`（意思是**任何網站的網頁都能讀取它的內容**）
- 任何路徑、任何方法都回同一份資訊，不需要登入

**出事會怎樣**
兩件事：

1. **資訊外洩**：機器指紋與事件編號是申請解鎖時要提供的兩項資料。它們本身不是密碼（原廠簽發時還會驗證申請者身分），但把它們公開等於把**申請解鎖的材料先送給對方**。若原廠的簽發流程只憑這兩個值就發，那問題就嚴重得多——**這一點要請簽發端（License Center）確認**，不在本棒範圍。

2. **可被塞爆**：這個殼用的是 Python 內建的 `ThreadingHTTPServer`，**每來一個連線就開一條執行緒，沒有上限**。有人持續開連線就能把記憶體吃光。不過機器已經被鎖定了，服務本來就不可用，所以實際損害有限。

**實際暴露程度沒有想像中大**：正式部署的 `docker-compose.yml` 裡，BE 服務用的是 `expose: 8000` 而不是 `ports:`，意思是**只在容器內部網路可見，不對主機外開放**（`docker-compose.yml:279-283`，註解明確寫「對外不開 port」）。整組系統唯一對外的是前端 nginx（`:318-325`）。所以要打到這個殼，得先進到容器網路內。**這是它是 LOW 的主要理由。**

**要先有什麼才打得到**
- 連得到容器內部網路（同一台主機上的其他容器、或已進入內網的攻擊者）
- 若部署時有人加了 `ports: ["8000:8000"]` 排錯後忘記拿掉（compose 註解正好提醒過這件事），**暴露面就直接擴大到主機網路**

**在哪裡**
- `jedi_integrity/config.py:93` — `lockdown_bind_host = "0.0.0.0"`
- `jedi_integrity/lockdown.py:182` — `_send_cors_headers()`，`Access-Control-Allow-Origin: *`
- `jedi_integrity/lockdown.py:51` — 用 `ThreadingHTTPServer`（每連線一執行緒、無上限）
- `jedi_integrity/lockdown.py:66-69` — `_lockdown_data()`，吐出的三個欄位
- 部署現況：`docker/production/docker-compose.yml:279-283`（`expose` 而非 `ports`）

**怎麼修**
這條的修法要謹慎，因為**綁 `0.0.0.0` 與 CORS 星號都是有真實理由的**（`lockdown.py:32-38` 說明：前端與後端不同源時，若 preflight 被擋，瀏覽器只會顯示網路錯誤而不是鎖定頁，那條「前端認碼跳鎖定頁」的契約就斷了）。建議：

1. **維持現狀但明列於部署文件**：告訴部署方「鎖定殼會對容器網路公開機器指紋與事件編號」，並強調 `ports:` 不可留。**這是成本最低、風險降幅最大的一項**。
2. 若要收斂：把 HTML 頁（給人看的，帶完整資訊）與 JSON（給前端認碼用的，可以只回 `error_code` 不帶指紋）分開——前端跳鎖定頁只需要 error_code，指紋可以要求從主機 log 或 console 取得。
3. `ThreadingHTTPServer` 換成有連線數上限的做法，或在殼前面加簡單的連線計數。

**首腦核對註記**：工具未報（**工具未報，runner 人工查證**）。我另外查了 compose 的實際暴露設定才把嚴重度定在 LOW——若只讀套件程式碼會誤判成 MEDIUM 以上。**部署設定是這條的關鍵變數**：任何把 8000 對外映射的部署，這條就要重新評估。

---

### T1-6（LOW）竄改事件回報會把伺服器 log 最後 50 行往外送

**這是什麼問題（白話）**
偵測到竄改時，產品會把事件回報給原廠的 License Center。回報內容除了「哪些檔案被改了」，還包含**伺服器 log 的最後 50 行**——用意是幫忙判斷「服務最後正常運作到什麼時候」，藉此推算竄改發生的時間窗。

問題是：**沒有人檢查那 50 行裡有什麼**。應用程式的 log 常常會夾帶意料之外的東西——錯誤堆疊裡的連線字串、debug 訊息裡的 token、SQL 全量 log 裡的查詢內容。

另外，回報時會在 HTTP header 帶上 License Center 的 API token，而宿主端這個位址的**預設值是明文 http**（`http://127.0.0.1:5062`）。

**出事會怎樣**
- log 尾段若剛好含有密碼或金鑰，會隨事件送到原廠伺服器並存進資料庫。這**不是外洩給攻擊者**（收件方是原廠），但屬於「敏感資料流到非預期的地方」，且會長期存放
- 若部署時 `LICENSE_ACTIVATION_SERVER_URL` 真的設成明文 `http://`，那 token 與 log 內容會在網路上裸奔

**為什麼是 LOW 不是更高**：預設值 `127.0.0.1` 是本機位址，明文傳輸不出這台機器；要出事得部署方把它設成外部的 http 位址。而 log 外洩的收件方是原廠自己，不是攻擊者。

**工具對這條的判斷**：三位檢查員一致否決了另一個相關的疑慮——「API token 會不會被轉址騙去攻擊者的伺服器」。理由是回報位址由部署設定決定，攻擊者插不了手。**我同意這個判斷**，所以那條不列入。這裡列的是不同的問題：**送出去的內容本身沒有過濾**。

**要先有什麼才打得到**
- 需要先觸發一次竄改事件（回報才會發生）
- 對「明文傳輸」那半：需要部署方把回報位址設成外部 http 位址

**在哪裡**
- `jedi_integrity/forensic.py:108-129` — `_log_tail()`，取 log 尾端 50 行，**無任何內容過濾**
- `jedi_integrity/forensic.py:43-44` — `_LOG_TAIL_BYTES = 64 * 1024`、`_LOG_TAIL_LINES = 50`
- `jedi_integrity/lc_report.py:69-79` — 送出的 request，header 帶 `X-API-Token`
- `common/integrity/adapters.py:55` — `_DEFAULT_LC_URL = "http://127.0.0.1:5062"`（明文 http，本機位址）
- 附帶：`common/integrity/adapters.py:212-330` 的 `collect_host_trigger_context()` 還會塞**登入帳號、來源 IP、活躍 session 清單**進去。這些是刻意蒐集的證據，屬設計意圖，但同樣會隨事件外送

**怎麼修**
1. 在 `forensic.py:_log_tail()` 回傳前加一層**遮罩**：用正規表示式把常見的敏感樣式打碼（`password=`、`token=`、`Authorization:`、`secret`、長度像金鑰的十六進位字串）。這是低成本、高效益的一步。
2. 在 `lc_report.py:report_tamper_best_effort()` 開頭檢查 `base_url`：**若不是 `https://` 且不是本機位址，就拒送並印警告**。目前任何位址都照送。
3. 部署文件明列：回報位址若指向外部，必須是 https。

**首腦核對註記**：工具未報這條（**工具未報，runner 人工查證**）。工具的研究員提出的是相鄰但不同的問題（轉址竊取 token），三票否決，我核對後同意否決。**遵守卡片紀律，本報告未印出任何金鑰值，也未讀取任何實際 log 內容**——上述判斷全部來自程式碼結構。

---

## 5. 卡片「重點看什麼」十項逐項回覆

卡片列了十個要追的點。逐項交代，**沒有留白**：

| # | 卡片的疑問 | 結論 |
|---|---|---|
| **①** | 能不能讓「打包判定」回 False 來關掉整套保護？`force_enforcement` 只能開不能關的主張成立嗎？ | **主張成立，但有另一個形狀的洞。** `enforcement_enabled()`（`manifest.py:83-85`）確實是打包模式恆回 True，旗標只能加嚴。`is_packaged()`（`resource_path.py:26-28`）看的是 Nuitka 編譯期注入的 `__compiled__` 全域變數，執行期改不動。**但卡片預見的「另一種形狀」成立了**——`GUIDANT_RESOURCE_ROOT` 能換掉執法對象，見 **T1-3**。 |
| **②** | 刪掉 FS 標記檔，服務是不是就正常起來？DB 那一半真的沒人呼叫嗎？ | **兩者都成立，這是本棒最重要的發現，見 T1-1。** `exists()` 零呼叫者已跨兩個 repo grep 確認。 |
| **③** | 把 `used-nonces.json` 改成亂碼，用過的解鎖檔能不能再用？ | **能，但可利用的情境很窄，見 T1-4。** 三道關卡（指紋／事件編號／效期）把它壓在 MEDIUM。`_MAX_USED_NONCES` 滿了丟最舊的**不構成獨立風險**（被丟的早已過期）。 |
| **④** | `cryptography` 是不是在 thirdparty 層？換掉它的 `.so` 能不能讓驗章永遠通過？ | **是，且能，見 T1-2。** 已循 build 腳本查證（不是猜）：`resolve_excluded_closure()` 遞迴求相依封閉集合，`:365` 明列 `pdfminer.six → cryptography`。**未實測掉包**。 |
| **⑤** | 鎖定殼公開機器指紋與事件編號有沒有問題？能不能塞爆？ | **有，但實際暴露面比預期小，見 T1-5（LOW）。** 正式部署用 `expose` 不用 `ports`，只在容器網路可見。塞爆確實可行（無上限執行緒）但機器已鎖定，損害有限。 |
| **⑥** | 回報用明文 http 嗎？token 從哪來？log 尾段會不會夾帶密碼？ | **預設是明文 http 但指向本機**（`127.0.0.1:5062`），不出這台機器；log 尾段**確實沒有任何過濾**，見 T1-6（LOW）。工具提出的「轉址竊取 token」三票否決，我核對後同意。 |
| **⑦** | `register_gunicorn_master` 無保護，能不能登記到錯的 pid？有沒有「訊號送不到、自己也沒退出」的組合？ | **查了，沒有可利用的問題。** 登記函式（`termination.py:54-67`）確實是模組全域無保護，但**要呼叫它就得先能在該進程內執行程式碼**——已經到這一步的攻擊者不需要繞這條路。宿主的接線正確（`main.py:223-225` 傳 `server.pid`，在 worker 內執行）。「送不到又沒退出」的組合**不存在**：`terminate_process()`（`:107-115`）把所有送訊號的例外都吞掉，但**最後一行 `os._exit(1)` 在迴圈外、無條件執行**，自己一定會死。 |
| **⑧** | 改本機時鐘能不能讓過期的解鎖檔復活？`expires_at` 缺欄位或畸形值走哪條？ | **理論上能改時鐘，但這條不構成獨立問題。** `unlock.py:179` 確實用本機 `datetime.now()` 比對，改時鐘可讓過期檔復活——但能改系統時鐘的人已有 root，直接刪標記檔（T1-1）成本更低，**這條在 T1-1 面前沒有額外價值**。欄位處理是穩健的：缺欄位或無法解析 → `raise UnlockRejected`（`:174-176`，走拒絕）；`"9999-12-31"` 是合法 ISO 日期、會被當成未過期，但仍要過指紋、事件編號、nonce 三關，**單靠它無法解鎖**。naive 時間當 UTC（`:177-178`）與寫入端（`tamper_marker.to_utc_iso()`）一致，無矛盾。 |
| **⑨** | 「先刪 manifest 再走一次匯出流程」會不會讓埋點永久失效？ | **會，但空窗有上限，判定為「已知取捨」不另列發現。** `_init_hotpath_cache()`（`runtime_check.py:220-241`）manifest 讀不到時回 `[]` 並 cache，**之後不再重讀**（`hotpath_verify` 只在 cache 為 `None` 時初始化），所以該進程的埋點確實永久變成空轉。但：①埋點本來就定位為「抽查的補強不是主防線」；②4 小時一次的抽查會以完整流程補上判定，且抽查**每輪重新讀取＋驗章 manifest、不 cache**（`:94` 註解明說），所以刪 manifest 會在下一輪抽查被抓到（`load_verified_manifest` 失敗即判竄改）。空窗上限是一個抽查週期。**真正讓這條失效的是 T1-2**（驗章工具被掉包後抽查也一起失效），所以修 T1-2 才是關鍵。 |
| **⑩** | 開發用啟動殼 `dev_app.py`：寫死的金鑰、tmp 目錄權限、`--keep`；CM-1572 的無界解壓真的補齊了嗎？ | **CM-1572 確認已修**：`_bounded_unpack()`（`harness/dev_app.py:95-105`）雙重上限（32 MiB base64 字串、16 MiB 解壓後）並檢查 `unconsumed_tail`，`:117-134` 的驗章走的正是它。**無寫死金鑰**：`Harness.__init__`（`:265`）每次執行現場產生 Ed25519 金鑰對，不入版控。**tmp 目錄**用 `tempfile.mkdtemp()`（`:255`），權限為 0700（僅擁有者可讀），`--keep` 只是不刪除、不改權限。**這支是開發工具、不進出貨產物，本身不構成產品風險。** 唯一可提的是它自帶一份與正式產品不同的驗章實作（`:107-141`），兩份程式碼可能分岔——但那是維護性問題不是安全問題。 |

---

## 6. 這份結果可信到什麼程度

分兩層講，因為這兩件事的可信度差很多。

### 第一層：「這些問題真的存在嗎？」——高

六條發現**全部由我逐檔開啟核對過**，每一條都能指到具體的檔名與行號，而且大多有程式碼註解自己佐證（T1-1 的補同步註解自陳「窗口縮到最小」、T1-4 的註解自陳「取寬鬆側」）。跨 repo 的事實（T1-2 的分層、T1-3 的環境變數、T1-5 的部署設定）也都是開檔查證，不是推測。

**但沒有任何一條做過實際攻擊驗證。** 依卡片紀律，不動任何環境的 `/opt/guidant/pki`、不觸發真實鎖定，所有繞過路徑都是「讀程式碼＋推論」的結果。**T1-2 特別建議下一棒在本機造假產物實測**——它是六條裡影響最大、也最依賴推論的一條。

### 第二層：「只有這些嗎？」——低，這一輪遠不能算掃透

三個具體理由：

1. **工具這一輪幾乎沒有產出。** 兩個研究員只提出 2 條候選，三位檢查員投票後**兩條都沒過關**，工具的正式報告是零發現。本報告六條裡有**五條是我人工查出來的**，工具只碰到 T1-1 的一部分。這是連續第四個 arc 出現同樣情形。

2. **`low` 是快篩不是徹查。** 沒有清點階段、沒有威脅建模、沒有廣掃，就是一輪讀＋一輪投票。

3. **研究員沒交「哪些檔沒讀完」的自述**，所以無法從工具端說明 21 個檔各自被讀到什麼程度。

**還沒被涵蓋的面向**（建議列入後續規劃）：
- **簽發端（License Center）**：解鎖檔怎麼簽、憑什麼決定要簽給誰。若簽發只憑機器指紋＋事件編號，T1-5 的資訊外洩就要重新評估嚴重度。
- **驗章實作本身**（`jedi_license_runtime.common.engine`）：本棒只驗證了「呼叫鏈通到哪裡」，沒有審查 Ed25519 驗章的實作細節。三環境公鑰全編在同一份產物內（總表 §3.1 第 6 項已記）。
- **build 端的 manifest 產生流程**：T1-2 揭露了分層判準會直接決定保護範圍，這條產線值得單獨檢視。
- **`tests/`**：不在本次範圍（9 個檔），沒有檢查測試是否真的涵蓋這些繞過路徑。

---

## 7. 執行概況

| 項目 | 數值 |
|---|---|
| 掃描範圍 | `jedi_integrity/` ＋ `harness/`，21 個受版控檔 |
| 版本 | commit `8aa6f0609c4434c0fa5206d5e99e31cad249646e` |
| 工具 | Claude Code `claude-security` plugin v0.11.0 |
| effort / focus | `low` / `attack-surface` |
| 研究員 | 派 2 個，回 2 個（零失敗） |
| 候選發現 | 2 條（去重後仍 2 條） |
| 檢查員投票 | 三個檢查員對 2 條發現各投一票，**6 票全數投出，沒有漏投** |
| 投票結果 | 候選 1：1 票成立 ／ 2 票否決（未達 3 票中要 2 票的門檻）；候選 2：0 票成立 ／ 3 票否決 |
| 工具正式發現 | **0 條** |
| 驗證章（stamp） | `verification.status: verified`（完整跑完，未中斷、未撞額度） |
| 驗證輪次 | 1 輪，無候選遺失、無未驗項目 |
| 執行時間 | 約 19 分鐘 |
| 工具產物 | `jedi-integrity/CLAUDE-SECURITY-20260910-113825/`（含自身 `.gitignore`，不入版控） |
| 本報告發現 | **6 條**（工具貢獻 1 條的一部分，其餘 5 條為 runner 人工查證） |

---

## 8. 建議的下一步

按投資報酬率排序：

1. **修 T1-2**（把驗章函式庫納入開機檢查）——它是唯一一條「一旦得手，整套機制永久失效」的路徑，且會連帶讓 T1-3、⑨的空窗問題失去價值。
2. **修 T1-3**（打包模式忽略 `GUIDANT_RESOURCE_ROOT`）——改動極小（一個 `if` 的順序），風險極低。
3. **決定 T1-1 怎麼處理**：補上資料庫檢查，或把文件改成與程式一致。**現狀最糟**——文件宣稱有兩道鎖，維護者因此不會去補。
4. **修 T1-4**（區分「檔案不存在」與「內容毀損」）與 **T1-6 的 log 遮罩**——都是小改動。
5. **T1-5 先寫進部署文件**即可，程式改動可延後。
6. **實測驗證 T1-2**：在本機造一份假產物、掉包驗章函式庫，確認繞過真的成立。

---

*本報告依 `security-scan-lead` skill 第十節白話規則撰寫。所有發現均為讀程式碼與 build 腳本推論的結果，**未在任何環境實際執行攻擊**，未觸碰任何環境的 `/opt/guidant/pki`，未印出任何金鑰值。*
