AI Code Review:用 Claude Code 檢查 Legacy 程式碼的安全性
這篇文章假設你對 Legacy PHP 的架構有基本了解。如果想先了解我們的系統背景,可以先閱讀: 📖 Claude Code 實戰:用 AI 除錯 Legacy PHP 專案
🎯 前言
Legacy PHP 專案就像定時炸彈。
不是說它一定會出問題,而是你永遠不知道當年的開發者有沒有做好安全防護。那個年代的 PHP 開發慣例中,$_GET['id'] 直接拼入 SQL 是「正常寫法」,echo $_POST['content'] 也沒人多想一下。
重要的是:在多租戶架構下,一個 SQL Injection 漏洞的影響範圍是整個 demo-org-1 的資料,不只是某一個使用者。
但我想澄清一件事:用 Claude Code 做安全審查,不是一鍵掃描就產生報告。 它是一個需要你引導的協作過程。你要告訴 Claude 看哪裡、問什麼問題、用什麼視角——Claude 才能給你有意義的發現。
🐛 Legacy PHP 的安全風險全景
在動手審查之前,先了解 Legacy PHP 的典型安全風險樣貌:
時代背景問題
PHP 早期的教學和範例(包括很多書籍)普遍沒有安全意識:
// 2010 年代常見的「教科書寫法」——現在看都是漏洞
$id = $_GET['id'];
$result = mysql_query("SELECT * FROM users WHERE id = $id");
echo $result['content'];
這三行就包含了 SQL Injection 和 XSS 兩個漏洞。
多租戶的放大效應
在 AppSystem 的多租戶架構中,安全漏洞的影響範圍更大:
- SQL Injection:可能洩漏整個機構的資料(含所有個案資料)
- Session 操控:可能切換到其他機構的資料庫(
$_SESSION['mcareDBno']) - 越權存取:機構 A 的使用者理論上可以存取機構 B 的資料
🛠️ 安全審查的準備工作
劃定審查範圍
不要試圖一次審查整個專案。按模組或功能分批進行:
優先審查的範圍:
1. 所有接收使用者輸入的端點(表單儲存、查詢 API)
2. 涉及跨機構操作的功能
3. 檔案上傳功能
4. 身份驗證和 Session 管理
問題記錄格式
給 Claude 明確的輸出格式要求,讓結果可以直接存入文件:
發現問題時,請用以下格式輸出:
**[嚴重度] 問題標題**
- 檔案路徑:xxx.php(第 XX 行)
- 問題描述:
- 攻擊情境:
- 修復建議:
- 修復難度:低 / 中 / 高
嚴重度分類:
- 🔴 Critical:可直接被利用,影響資料安全
- 🟠 High:需要一定條件才能利用
- 🟡 Medium:有風險但難以直接利用
- 🟢 Low:最佳實踐問題,暫無直接風險
❌ 真實發現一:SQL Injection
問題程式碼
// module_a/report/list.php(簡化版)
$org_id = $_GET['org_id'];
$start_date = $_GET['start_date'];
$sql = "SELECT * FROM case_records
WHERE org_id = $org_id
AND created_at >= '$start_date'
ORDER BY created_at DESC";
$result = $db->query($sql);
攻擊情境:攻擊者修改 org_id 參數為 1 OR 1=1,可取得所有機構的資料。
常見的「半套防護」
Legacy 程式碼中常見用 intval() 做防護,但這只適用於純數字的情境:
// ⚠️ 只對數字有效,日期格式仍有風險
$org_id = intval($_GET['org_id']); // 可以
$start_date = intval($_GET['start_date']); // 把日期變成 0,邏輯壞了
正確修復:PDO Prepared Statement
// ✅ 修復後
$org_id = (int) $_GET['org_id']; // 數字型別強制轉型
$start_date = $_GET['start_date']; // 字串用 PDO binding 處理
$stmt = $db->prepare(
"SELECT * FROM case_records
WHERE org_id = :org_id
AND created_at >= :start_date
ORDER BY created_at DESC"
);
$stmt->execute([
':org_id' => $org_id,
':start_date' => $start_date,
]);
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
❌ 真實發現二:XSS(跨站腳本攻擊)
問題程式碼
// ❌ 直接輸出使用者輸入
<div class="note">
<?php echo $_POST['note']; ?>
</div>
// ❌ 從資料庫取出後直接輸出(資料庫儲存時沒有 escape)
<td><?php echo $row['case_name']; ?></td>
Legacy 模板中這類寫法非常常見。攻擊者可以在輸入欄位放入 <script>alert(document.cookie)</script>,受害者開啟頁面時就會執行。
修復方式
先定義一個 helper 函式:
// include/helpers.php
/**
* HTML 輸出時的安全 escape
* 對所有需要輸出到 HTML 的值使用此函式
*/
function h(string $value): string {
return htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
然後在模板中統一替換:
// ✅ 修復後
<div class="note">
<?= h($_POST['note']) ?>
</div>
<td><?= h($row['case_name']) ?></td>
遷移策略:不要試圖一次改完所有地方。先在公用 header 定義 h() 函式,然後以模組為單位,新功能強制要求,舊功能隨修 bug 時順便補上。
❌ 真實發現三:CSRF 保護缺失
問題所在
許多表單儲存端點沒有 CSRF token 驗證:
// module_a/case/save.php
// ❌ 直接處理 POST,沒有驗證 token
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$case_id = $_POST['case_id'];
// ... 儲存邏輯
}
攻擊者可以製作一個惡意網頁,誘使已登入的使用者「不知不覺」提交表單,執行刪除資料等操作。
分批修復策略
CSRF 保護不容易全部一次補完,建議分批進行:
優先修復順序:
1. 🔴 刪除類操作(delete、remove)
2. 🔴 身份驗證相關(登入、修改密碼)
3. 🟠 批次操作(影響多筆資料)
4. 🟡 一般儲存表單
// 簡單的 CSRF token 實作
// session.php(在所有頁面載入前引入)
function generate_csrf_token(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf_token(): bool {
$token = $_POST['csrf_token'] ?? '';
return hash_equals($_SESSION['csrf_token'] ?? '', $token);
}
// 表單中
<input type="hidden" name="csrf_token" value="<?= h(generate_csrf_token()) ?>">
// 儲存端點
if (!verify_csrf_token()) {
http_response_code(403);
exit('CSRF token 驗證失敗');
}
❌ 真實發現四:其他常見問題
display_errors 在正式環境開啟
// ❌ php.ini 或程式碼中
ini_set('display_errors', 1);
error_reporting(E_ALL);
正式環境顯示錯誤訊息,會暴露路徑、資料庫結構、甚至帳號資訊。
// ✅ 根據環境設定
if ($_ENV['APP_ENV'] === 'production') {
ini_set('display_errors', 0);
error_reporting(0);
// 改為記錄到 log
ini_set('log_errors', 1);
ini_set('error_log', '/var/log/php_errors.log');
}
檔案上傳 MIME 驗證缺失
// ❌ 只檢查副檔名,容易繞過
$ext = pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION);
if (in_array($ext, ['jpg', 'png', 'pdf'])) {
// 允許上傳
}
// ✅ 同時驗證 MIME type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['file']['tmp_name']);
finfo_close($finfo);
$allowed_mimes = ['image/jpeg', 'image/png', 'application/pdf'];
if (!in_array($mime, $allowed_mimes)) {
// 拒絕上傳
}
Session Fixation
// ❌ 登入後沒有更新 session ID
function login($user_id) {
$_SESSION['user_id'] = $user_id;
// 漏洞:使用攻擊者預先知道的 session ID
}
// ✅ 登入時重新產生 session ID
function login($user_id) {
session_regenerate_id(true); // 舊的 session ID 失效
$_SESSION['user_id'] = $user_id;
}
📊 整理發現:優先順序矩陣
審查完一個模組後,請 Claude 幫你整理成表格,方便排優先順序:
我:
以上是 module_a 的安全審查發現,請幫我:
1. 整理成 Markdown 表格,包含嚴重度、位置、修復難度
2. 建議修復優先順序
3. 輸出為可以存進 docs/SECURITY_ISSUES.md 的格式
範例輸出(存入 docs/SECURITY_ISSUES.md):
# Security Issues - module_a
最後更新:2026-02-11
| 嚴重度 | 問題 | 位置 | 修復難度 | 狀態 |
|--------|------|------|----------|------|
| 🔴 Critical | SQL Injection - 查詢端點 | report/list.php:15 | 中 | 待修 |
| 🔴 Critical | SQL Injection - 儲存端點 | case/save.php:32 | 中 | 待修 |
| 🟠 High | XSS - 輸出未 escape | case/view.php:多處 | 低 | 待修 |
| 🟠 High | CSRF 保護缺失 - 刪除操作 | case/delete.php | 中 | 待修 |
| 🟡 Medium | display_errors 開啟 | config.php:5 | 低 | 待修 |
| 🟡 Medium | 檔案上傳 MIME 驗證缺失 | upload/handler.php | 中 | 待修 |
💡 讓 Claude 做安全審查的技巧
技巧 1:分模組審查
❌ 低效
「幫我審查整個 module_a 的安全性」
✅ 高效
「幫我審查 module_a/case/ 目錄下所有接收 $_POST 和 $_GET 的端點,
重點找 SQL Injection 和 XSS 問題。
以下是其中一個代表性的檔案:[貼上程式碼]」
技巧 2:攻擊者視角
我:
請用攻擊者的角度思考:如果我是一個知道這個系統架構的攻擊者,
我會優先嘗試哪些攻擊方式?
我的目標是取得 demo-org-1 的所有個案資料。
這個問法能讓 Claude 跳出「逐行審查」的模式,給出更有戰略性的建議。
技巧 3:區分確認漏洞 vs 潛在風險
審查結果請分兩類:
**已確認漏洞**(可以寫出具體攻擊步驟的):
- ...
**潛在風險**(需要更多上下文確認的):
- ...
這樣可以讓開發者優先處理已確認的問題,而不是在潛在風險上花太多時間。
🎉 結語
Legacy PHP 的安全審查不可能一蹴而就,但也不用等到「全部修好」才安全。
我的建議是:
- 先用 Claude 快速掃一輪,建立
docs/SECURITY_ISSUES.md問題清單 - 依嚴重度排序,Critical 的問題優先修
- 新寫的程式碼強制要求安全寫法(PDO、
h()、CSRF token) - 舊程式碼隨著其他修改順便補上
「讓新程式碼是乾淨的」比「把所有舊程式碼改乾淨」更可持續。
📎 相關文章: