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 的安全審查不可能一蹴而就,但也不用等到「全部修好」才安全。

我的建議是:

  1. 先用 Claude 快速掃一輪,建立 docs/SECURITY_ISSUES.md 問題清單
  2. 依嚴重度排序,Critical 的問題優先修
  3. 新寫的程式碼強制要求安全寫法(PDO、h()、CSRF token)
  4. 舊程式碼隨著其他修改順便補上

「讓新程式碼是乾淨的」比「把所有舊程式碼改乾淨」更可持續。


📎 相關文章