[安全教育] 002 資安 KPI 怎麼定?用數據證明安全投資的價值|關鍵指標與成效追蹤實戰指南

「沒有度量,就沒有改進。沒有改進,就沒有信任。
你花了三個月導入 SSDLC,老闆問你『成效在哪?』——你拿得出數據嗎?
安全不只要做得好,還要讓人看得見。」
— SSDLC by 飛飛


一、資安指標是什麼?為什麼你需要一張「安全體檢報告」?

在 SSDLC 蓋房子的旅程中,我們已經走過了安全需求(確認要防震防火防盜)、安全設計(畫好建築藍圖)、安全開發(用防火建材施工)、安全測試(請結構技師來驗收)、安全部署(交屋前檢查門窗設定)、安全維運(裝好監視器和煙霧偵測器)。現在,我們來到了階段七:安全教育與持續改進(Education & Continuous Improvement)——如何用數據衡量這一切努力的成果。

想像你花了一大筆錢把房子全面翻修:換了防盜門窗、裝了消防灑水系統、加了監視器和保全。然後你老婆問你:「花了這些錢,到底有沒有比較安全?」

你說:「有啊,感覺比較安全了。」

她說:「什麼叫『感覺』?給我看數據。」

這就是資安指標在做的事——把「感覺比較安全」變成「可以證明比較安全」

在企業裡,這個場景更加現實:你向老闆申請了 NT$50 萬的資安預算,導入了 SAST 掃描、買了 DAST 工具、送工程師去上資安課程。年底績效評估時,老闆問:「這 50 萬花得值嗎?明年還要繼續投嗎?」

如果你只能回答「呃,我們比較安全了」,那明年的預算大概率會被砍。但如果你能拿出一張報告:

  • 高風險漏洞從上線前平均 12 個降到 2 個
  • 漏洞修復時間從 14 天縮短到 3 天
  • 全年零資安事件,避免了預估 NT$500 萬的潛在損失

這就是用數據為安全投資背書,讓「安全」從成本中心變成價值中心。

飛飛觀點:
我曾經跟一位 CTO 聊天,他說:「資安就像買保險,花了錢如果沒出事,大家覺得浪費;出了事才後悔沒買。」資安指標就是解決這個困境的方法——讓安全的價值在「沒出事」的時候也能被量化、被看見。


二、傳統做法 vs. 數據驅動的安全管理

先來看看傳統做法和數據驅動做法的差別:

面向 傳統做法 數據驅動的安全管理
成效評估 「感覺比較安全了」 「高風險漏洞減少 83%」
預算申請 「我們需要更多資安投資」 「每投入 NT$1 在 SAST,省下 NT$8 的修復成本」
風險溝通 「系統可能有漏洞」 「目前有 3 個高風險漏洞,預估影響 2 萬名用戶」
改善方向 憑直覺決定 用數據找出最該投資的環節
向上報告 技術人員自己懂就好 管理層也能看懂的儀表板
團隊激勵 「大家要注意安全」 「這季的安全分數提升了 15 分,做得好!」

用蓋房子的比喻來說:

傳統做法就像裝修完房子後,問住戶「覺得怎麼樣?」——大家說「不錯啊」,但你不知道到底哪裡好、哪裡還需要加強。

數據驅動就像請驗屋師來出一份完整的檢測報告:結構強度 95 分、消防系統合格率 100%、門窗氣密性 88 分——你清楚知道整體狀況,也知道該把下一筆預算花在哪裡(氣密性)。


三、資安指標的兩大支柱:KPI 與 KRI

在進入具體指標之前,先搞清楚兩個核心概念:

KPI(Key Performance Indicator):關鍵績效指標

KPI 回答的問題是:「我們做得好不好?」

KPI 衡量的是你的安全措施的「成效」。就像學生的考試成績——用來評估學習效果、追蹤進步趨勢。

範例:

  • 漏洞修復時間(MTTR)是否在縮短?
  • CI/CD Pipeline 的安全掃描覆蓋率是否在提升?
  • 團隊的資安訓練完成率是多少?

KRI(Key Risk Indicator):關鍵風險指標

KRI 回答的問題是:「我們有多危險?」

KRI 衡量的是你的系統「當前面臨的風險水平」。就像體溫計——用來監測健康狀況,超標就要注意。

範例:

  • 目前有多少個未修補的高風險漏洞?
  • 過去 30 天發生了幾次安全事件?
  • 有多少個第三方套件存在已知 CVE?

KPI vs. KRI 的關係

面向 KPI(績效指標) KRI(風險指標)
回答什麼 我們做得好不好? 我們有多危險?
看的方向 回顧過去的表現 預警未來的風險
比喻 學生的期末成績 學生的健康檢查報告
目標 越高越好(或趨勢向好) 越低越好(或控制在閾值內)
範例 漏洞修復速度、訓練完成率 未修補漏洞數、暴露的攻擊面

飛飛觀點:
很多團隊只追蹤 KPI 卻忽略 KRI,就像只看考試成績卻不做健康檢查。成績再好,如果身體出問題也沒用。反過來說,只看 KRI 而不看 KPI,就像每天量體溫卻不運動——知道自己有風險,但不知道怎麼變好。兩者都要看,才是完整的資安成效追蹤。


四、關鍵安全指標清單:你的團隊應該追蹤什麼?

以下是我整理的資安指標體系,分為五大類別。不需要一次全做,後面會教你怎麼挑選最適合你的指標。

4.1 漏洞管理指標

這是最基本也最重要的一類指標,衡量你發現和修復漏洞的能力。

指標名稱 類型 計算方式 目標方向 說明
漏洞密度 KRI 漏洞數 / 千行程式碼(KLOC) ↓ 越低越好 程式碼的「安全品質密度」
高風險漏洞數 KRI CVSS ≥ 7.0 的未修補漏洞總數 ↓ 越低越好 當前的風險暴露程度
平均修復時間(MTTR) KPI 從發現到修復的平均天數 ↓ 越短越好 團隊的修復效率
修復率 KPI 已修復漏洞 / 已發現漏洞 × 100% ↑ 越高越好 漏洞是否有被處理
漏洞逃逸率 KPI 上線後才發現的漏洞 / 總漏洞數 × 100% ↓ 越低越好 開發階段的攔截能力
漏洞重開率 KPI 修復後又重現的漏洞 / 已修復漏洞 × 100% ↓ 越低越好 修復品質是否到位

台灣企業的合理目標參考:

指標 起步階段 成長階段 成熟階段
漏洞密度 < 10 / KLOC < 5 / KLOC < 2 / KLOC
高風險漏洞 MTTR < 30 天 < 14 天 < 7 天
中風險漏洞 MTTR < 90 天 < 30 天 < 14 天
漏洞逃逸率 < 30% < 15% < 5%

4.2 安全流程指標

衡量安全工具和流程的落實程度。

指標名稱 類型 計算方式 目標方向 說明
安全掃描覆蓋率 KPI 有跑安全掃描的 Repo / 總 Repo 數 × 100% ↑ 越高越好 CI/CD 安全整合的覆蓋程度
掃描通過率 KPI 通過安全品質門檻的 Build / 總 Build 數 × 100% ↑ 越高越好 程式碼品質的趨勢
安全審查率 KPI 經過安全審查的 PR / 總 PR 數 × 100% ↑ 越高越好 Code Review 中安全的涵蓋率
誤報率 KPI 確認為誤報的告警 / 總告警數 × 100% ↓ 越低越好 工具的精準度(太高會導致信任流失)
第三方套件漏洞比例 KRI 有已知 CVE 的套件 / 總套件數 × 100% ↓ 越低越好 供應鏈風險暴露程度

4.3 安全事件指標

衡量你的偵測和回應能力。

指標名稱 類型 計算方式 目標方向 說明
平均偵測時間(MTTD) KPI 從事件發生到被偵測的平均時間 ↓ 越短越好 監控系統的靈敏度
平均回應時間(MTTR) KPI 從偵測到開始處理的平均時間 ↓ 越短越好 事件回應的效率
安全事件數 KRI 過去 N 天/月的安全事件總數 ↓ 越低越好 整體安全態勢
事件復發率 KPI 同類型事件再次發生的比例 ↓ 越低越好 根因是否被徹底解決

4.4 人員與文化指標

衡量團隊的安全意識和能力。

指標名稱 類型 計算方式 目標方向 說明
資安訓練完成率 KPI 完成訓練的人數 / 應訓練人數 × 100% ↑ 越高越好 教育訓練的覆蓋率
Security Champion 覆蓋率 KPI 有 Champion 的團隊 / 總團隊數 × 100% ↑ 越高越好 安全文化的滲透程度
內部安全回報數 KPI 團隊成員主動回報的安全問題數 ↑ 越高越好 安全文化的健康度
安全知識評量分數 KPI 團隊在安全測驗中的平均分數 ↑ 越高越好 安全知識的實際掌握度

4.5 商業影響指標

讓管理層看得懂的指標,連結安全與商業價值。

指標名稱 類型 計算方式 目標方向 說明
安全修復成本節省 KPI 開發階段修復成本 vs. 上線後修復成本的差額 ↑ 越高越好 左移策略的經濟效益
因安全事件導致的停機時間 KRI 因資安事件導致的服務中斷總時數 ↓ 越低越好 安全對可用性的影響
合規差距數 KRI 未滿足法規要求的項目數 ↓ 越低越好 法規風險暴露程度
安全投資報酬率(ROSI) KPI (預期損失 – 實際損失 – 安全投資)/ 安全投資 × 100% ↑ 越高越好 安全投資的整體效益

五、實戰:用 Node.js 建立資安指標追蹤系統

讓我們用一個台灣電商平台的場景,從頭到尾建立一套資安指標追蹤系統。

5.1 定義度量資料結構

// security-metrics.js — 資安指標資料收集模組

/**
 * 資安指標的資料結構定義
 * 用於追蹤 SSDLC 各階段的安全成效
 */
const SecurityMetricsSchema = {
  // 報告基本資訊
  reportPeriod: {
    startDate: '2026-01-01',
    endDate: '2026-03-31',
    quarter: 'Q1-2026',
  },

  // 4.1 漏洞管理指標
  vulnerabilityMetrics: {
    totalDiscovered: 0,        // 本期發現的漏洞總數
    totalResolved: 0,          // 本期修復的漏洞總數
    openHighRisk: 0,           // 目前未修補的高風險漏洞
    openMediumRisk: 0,         // 目前未修補的中風險漏洞
    mttrHigh: 0,               // 高風險漏洞平均修復天數
    mttrMedium: 0,             // 中風險漏洞平均修復天數
    escapedToProduction: 0,    // 逃逸到正式環境的漏洞數
    reopened: 0,               // 修復後又重開的漏洞數
    densityPerKLOC: 0,         // 每千行程式碼的漏洞密度
  },

  // 4.2 安全流程指標
  processMetrics: {
    reposWithScanning: 0,      // 有安全掃描的 Repo 數
    totalRepos: 0,             // 總 Repo 數
    buildsPassed: 0,           // 通過安全門檻的 Build 數
    totalBuilds: 0,            // 總 Build 數
    prsWithSecurityReview: 0,  // 經安全審查的 PR 數
    totalPRs: 0,               // 總 PR 數
    falsePositives: 0,         // 誤報數
    totalAlerts: 0,            // 總告警數
    depsWithCVE: 0,            // 有已知 CVE 的套件數
    totalDeps: 0,              // 總套件數
  },

  // 4.3 安全事件指標
  incidentMetrics: {
    totalIncidents: 0,         // 安全事件總數
    mttdHours: 0,              // 平均偵測時間(小時)
    mttrHours: 0,              // 平均回應時間(小時)
    recurrentIncidents: 0,     // 復發的事件數
  },

  // 4.4 人員與文化指標
  cultureMetrics: {
    trainingCompleted: 0,      // 完成訓練的人數
    trainingRequired: 0,       // 應訓練的人數
    teamsWithChampion: 0,      // 有 Security Champion 的團隊數
    totalTeams: 0,             // 總團隊數
    internalReports: 0,        // 內部安全回報數
    avgQuizScore: 0,           // 安全測驗平均分數
  },
};

5.2 自動化資料收集

// collect-metrics.js — 從 CI/CD 工具自動收集度量資料
const { execSync } = require('child_process');

/**
 * 從 npm audit 收集依賴漏洞資料
 */
function collectDependencyMetrics(projectPath) {
  try {
    const auditResult = execSync(
      <code>cd ${projectPath} && npm audit --json 2>/dev/null</code>,
      { encoding: 'utf8' }
    );
    const audit = JSON.parse(auditResult);

    return {
      totalDeps: Object.keys(audit.vulnerabilities || {}).length,
      critical: countBySeverity(audit, 'critical'),
      high: countBySeverity(audit, 'high'),
      moderate: countBySeverity(audit, 'moderate'),
      low: countBySeverity(audit, 'low'),
      timestamp: new Date().toISOString(),
    };
  } catch (error) {
    console.error('npm audit 執行失敗:', error.message);
    return null;
  }
}

function countBySeverity(audit, severity) {
  return Object.values(audit.vulnerabilities || {})
    .filter(v => v.severity === severity).length;
}

/**
 * 從 Git 記錄計算程式碼行數(用於漏洞密度)
 */
function countLinesOfCode(projectPath) {
  try {
    // 使用 cloc 或簡單的 wc -l
    const result = execSync(
      <code>find ${projectPath}/src -name "*.js" -o -name "*.ts" | xargs wc -l | tail -1</code>,
      { encoding: 'utf8' }
    );
    const totalLines = parseInt(result.trim().split(/\s+/)[0], 10);
    return totalLines;
  } catch {
    return 0;
  }
}

/**
 * 計算漏洞密度
 */
function calculateVulnerabilityDensity(vulnCount, linesOfCode) {
  if (linesOfCode === 0) return 0;
  const kloc = linesOfCode / 1000;
  return parseFloat((vulnCount / kloc).toFixed(2));
}

/**
 * 從漏洞追蹤系統計算 MTTR(平均修復時間)
 * 假設使用 JSON 格式的漏洞記錄
 */
function calculateMTTR(vulnerabilities, severity = 'high') {
  const resolved = vulnerabilities
    .filter(v => v.severity === severity && v.resolvedAt)
    .map(v => {
      const discovered = new Date(v.discoveredAt);
      const resolved = new Date(v.resolvedAt);
      return (resolved - discovered) / (1000 * 60 * 60 * 24); // 天
    });

  if (resolved.length === 0) return 0;
  const avgDays = resolved.reduce((a, b) => a + b, 0) / resolved.length;
  return parseFloat(avgDays.toFixed(1));
}

module.exports = {
  collectDependencyMetrics,
  countLinesOfCode,
  calculateVulnerabilityDensity,
  calculateMTTR,
};

5.3 計算安全投資報酬率(ROSI)

這是讓老闆眼睛一亮的指標——用數字證明安全投資是划算的。

// rosi-calculator.js — 安全投資報酬率計算器

/**
 * ROSI(Return on Security Investment)計算
 *
 * 公式:ROSI = (預期年化損失 × 風險降低比例 - 安全投資成本) / 安全投資成本 × 100%
 *
 * 其中:
 * - ALE(Annualized Loss Expectancy)= SLE × ARO
 * - SLE(Single Loss Expectancy)= 單次事件的預估損失
 * - ARO(Annualized Rate of Occurrence)= 年化發生頻率
 */
function calculateROSI({
  singleLossExpectancy,   // 單次資安事件的預估損失(NT$)
  annualRateOfOccurrence, // 年化發生頻率(次/年)
  riskReductionRate,      // 導入安全措施後的風險降低比例(0-1)
  securityInvestment,     // 安全投資總成本(NT$)
}) {
  // 計算年化預期損失(ALE)
  const ale = singleLossExpectancy * annualRateOfOccurrence;

  // 計算導入安全措施後「避免的損失」
  const avoidedLoss = ale * riskReductionRate;

  // 計算 ROSI
  const rosi = ((avoidedLoss - securityInvestment) / securityInvestment) * 100;

  return {
    ale: Math.round(ale),
    avoidedLoss: Math.round(avoidedLoss),
    securityInvestment,
    rosi: parseFloat(rosi.toFixed(1)),
    netBenefit: Math.round(avoidedLoss - securityInvestment),
  };
}

// ============================
// 實際案例:台灣電商平台
// ============================

const ecommerceCase = calculateROSI({
  // 一次資料外洩事件的預估損失
  // 包含:罰鍰 + 客戶流失 + 法律費用 + 商譽損失
  singleLossExpectancy: 5000000,   // NT$500 萬

  // 根據產業統計,類似規模的電商平台
  // 每年發生重大資安事件的頻率約 0.3 次
  annualRateOfOccurrence: 0.3,

  // 導入 SSDLC(SAST + SCA + DAST + 教育訓練)後
  // 預估可降低 70% 的風險
  riskReductionRate: 0.7,

  // 年度安全投資
  // SAST 工具:NT$0(使用開源 Semgrep)
  // DAST 工具:NT$0(使用開源 ZAP)
  // 教育訓練:NT$100,000
  // 一次滲透測試:NT$250,000
  // 工程師時間成本:NT$150,000
  securityInvestment: 500000,      // NT$50 萬
});

console.log('=== 台灣電商平台 ROSI 分析 ===');
console.log(<code class="kb-btn">年化預期損失 (ALE):NT$${ecommerceCase.ale.toLocaleString()}</code>);
console.log(<code class="kb-btn">避免的損失:NT$${ecommerceCase.avoidedLoss.toLocaleString()}</code>);
console.log(<code class="kb-btn">安全投資:NT$${ecommerceCase.securityInvestment.toLocaleString()}</code>);
console.log(<code class="kb-btn">淨效益:NT$${ecommerceCase.netBenefit.toLocaleString()}</code>);
console.log(<code>ROSI:${ecommerceCase.rosi}%</code>);

// 輸出:
// === 台灣電商平台 ROSI 分析 ===
// 年化預期損失 (ALE):NT$1,500,000
// 避免的損失:NT$1,050,000
// 安全投資:NT$500,000
// 淨效益:NT$550,000
// ROSI:110%
// → 每投入 NT$1,產生 NT$1.10 的安全效益

飛飛觀點:
ROSI 不是精確的科學計算,而是一種「合理的估算」。重點不在於數字是否 100% 精確,而在於它提供了一個讓技術人員和管理層能夠「講同一種語言」的框架。當老闆問「為什麼要花這筆錢」,你能拿出一個有邏輯的數字,而不是只說「因為安全很重要」——這就夠了。

5.4 漏洞修復成本的「左移效益」

IBM 在其年度《資料外洩成本報告》中長年追蹤一個重要發現:漏洞越早發現,修復成本越低。以下是根據業界研究整理的相對成本:

// shift-left-cost.js — 左移策略的成本效益計算

/**
 * 不同階段發現漏洞的相對修復成本
 * 基準:需求階段 = 1x
 */
const RELATIVE_COST = {
  requirements: 1,    // 需求階段(寫規格時發現)
  design: 5,          // 設計階段(威脅建模時發現)
  implementation: 10, // 實作階段(Code Review / SAST 發現)
  testing: 15,        // 測試階段(DAST / 滲透測試發現)
  production: 30,     // 正式環境(上線後才發現)
  breach: 100,        // 資料外洩事件(被攻擊後才發現)
};

/**
 * 計算左移策略的成本節省
 */
function calculateShiftLeftSavings({
  vulnsFoundInRequirements,
  vulnsFoundInDesign,
  vulnsFoundInImplementation,
  vulnsFoundInTesting,
  vulnsFoundInProduction,
  baseCostPerVuln,  // 需求階段修復一個漏洞的基本成本(NT$)
}) {
  // 如果所有漏洞都在正式環境才發現的「最差情境」成本
  const totalVulns =
    vulnsFoundInRequirements +
    vulnsFoundInDesign +
    vulnsFoundInImplementation +
    vulnsFoundInTesting +
    vulnsFoundInProduction;

  const worstCaseCost = totalVulns * baseCostPerVuln * RELATIVE_COST.production;

  // 實際成本(在不同階段發現)
  const actualCost =
    vulnsFoundInRequirements * baseCostPerVuln * RELATIVE_COST.requirements +
    vulnsFoundInDesign * baseCostPerVuln * RELATIVE_COST.design +
    vulnsFoundInImplementation * baseCostPerVuln * RELATIVE_COST.implementation +
    vulnsFoundInTesting * baseCostPerVuln * RELATIVE_COST.testing +
    vulnsFoundInProduction * baseCostPerVuln * RELATIVE_COST.production;

  return {
    totalVulns,
    worstCaseCost: Math.round(worstCaseCost),
    actualCost: Math.round(actualCost),
    savings: Math.round(worstCaseCost - actualCost),
    savingsPercentage: parseFloat(
      (((worstCaseCost - actualCost) / worstCaseCost) * 100).toFixed(1)
    ),
  };
}

// 實際案例:某台灣金融科技公司的季度數據
const result = calculateShiftLeftSavings({
  vulnsFoundInRequirements: 3,    // 安全規格審查時發現 3 個
  vulnsFoundInDesign: 5,          // 威脅建模時發現 5 個
  vulnsFoundInImplementation: 12, // SAST / Code Review 發現 12 個
  vulnsFoundInTesting: 4,         // DAST / 滲透測試發現 4 個
  vulnsFoundInProduction: 1,      // 上線後才發現 1 個
  baseCostPerVuln: 5000,          // 需求階段修復一個漏洞約 NT$5,000
});

console.log('=== 左移效益分析 ===');
console.log(<code>總漏洞數:${result.totalVulns} 個</code>);
console.log(<code class="kb-btn">最差情境成本(全在線上發現):NT$${result.worstCaseCost.toLocaleString()}</code>);
console.log(<code class="kb-btn">實際成本(左移後):NT$${result.actualCost.toLocaleString()}</code>);
console.log(<code class="kb-btn">節省金額:NT$${result.savings.toLocaleString()}</code>);
console.log(<code>節省比例:${result.savingsPercentage}%</code>);

// 輸出:
// === 左移效益分析 ===
// 總漏洞數:25 個
// 最差情境成本(全在線上發現):NT$3,750,000
// 實際成本(左移後):NT$620,000
// 節省金額:NT$3,130,000
// 節省比例:83.5%

六、資安成效報告模板:可直接使用的季度報告

以下是一份完整的資安成效季度報告模板,你可以直接拿來用:

# 資安成效季度報告

## 報告資訊
- **報告期間**:2026 年 Q1(1 月 - 3 月)
- **報告日期**:2026/04/05
- **編製人**:[Security Champion 姓名]
- **審核人**:[技術主管姓名]

---

## 一、執行摘要(給管理層看的 30 秒版本)

本季安全態勢整體**改善**,主要亮點:
- ✅ 高風險漏洞修復時間從 21 天縮短至 7 天(改善 67%)
- ✅ CI/CD 安全掃描覆蓋率從 60% 提升至 85%
- ✅ 全季零重大安全事件
- ⚠️ 第三方套件漏洞比例偏高(12%),需加速更新

**安全投資效益**:本季安全投資 NT$150,000,預估避免損失 NT$500,000,ROSI 為 233%。

---

## 二、漏洞管理

| 指標 | 上季 | 本季 | 趨勢 | 目標 |
|------|------|------|------|------|
| 漏洞密度(/ KLOC) | 4.2 | 3.1 | ↓ 改善 | < 2.0 |
| 未修補高風險漏洞 | 5 | 2 | ↓ 改善 | 0 |
| 高風險 MTTR(天) | 21 | 7 | ↓ 改善 | < 7 |
| 中風險 MTTR(天) | 45 | 18 | ↓ 改善 | < 14 |
| 漏洞逃逸率 | 18% | 8% | ↓ 改善 | < 5% |
| 漏洞重開率 | 10% | 5% | ↓ 改善 | < 3% |

**分析**:SAST 工具(Semgrep)在 Q1 初導入後,顯著提升了開發階段的攔截能力,
漏洞逃逸率下降 10 個百分點。

---

## 三、安全流程

| 指標 | 上季 | 本季 | 趨勢 | 目標 |
|------|------|------|------|------|
| 安全掃描覆蓋率 | 60% | 85% | ↑ 改善 | 100% |
| 掃描通過率 | 72% | 81% | ↑ 改善 | > 90% |
| 安全審查率 | 30% | 55% | ↑ 改善 | > 80% |
| 誤報率 | 25% | 18% | ↓ 改善 | < 10% |
| 有 CVE 的套件比例 | 15% | 12% | ↓ 改善 | < 5% |

---

## 四、安全事件

| 指標 | 上季 | 本季 | 趨勢 |
|------|------|------|------|
| 安全事件總數 | 3 | 0 | ↓ 改善 |
| P1/P2 事件 | 1 | 0 | ↓ 改善 |
| MTTD(小時) | 48 | N/A | - |
| MTTR(小時) | 72 | N/A | - |

---

## 五、人員與文化

| 指標 | 上季 | 本季 | 趨勢 | 目標 |
|------|------|------|------|------|
| 資安訓練完成率 | 40% | 75% | ↑ 改善 | 100% |
| Security Champion 覆蓋率 | 25% | 50% | ↑ 改善 | 100% |
| 內部安全回報數 | 2 | 7 | ↑ 改善 | 持續增加 |

---

## 六、下季行動計畫

| 優先序 | 行動項目 | 負責人 | 預計完成日 |
|--------|---------|--------|-----------|
| P1 | 安全掃描覆蓋率提升至 100% | DevOps Lead | Q2 第 2 週 |
| P1 | 處理剩餘 2 個高風險漏洞 | 後端團隊 | Q2 第 1 週 |
| P2 | 第三方套件全面更新 | 各團隊 Champion | Q2 第 4 週 |
| P2 | 調整 SAST 規則降低誤報率 | 資安 Champion | Q2 第 6 週 |
| P3 | 全員資安訓練達 100% | HR + 資安 | Q2 第 8 週 |

飛飛觀點:
報告最重要的不是「有多少頁」,而是「前 30 秒能不能讓人看懂」。管理層通常只看執行摘要——所以那一段要用最簡潔的方式傳達「現在安全嗎?」和「錢花得值嗎?」兩個核心問題。詳細數據是給技術團隊深入分析用的。


七、用 OWASP SAMM 評估安全成熟度

除了追蹤單一指標,你也需要一個「全局視角」來評估團隊的安全開發成熟度。OWASP SAMM(Software Assurance Maturity Model) 就是這樣的工具。

SAMM 是什麼?

SAMM 把安全開發分成 5 個業務功能、15 個安全實務,每個實務有 3 個成熟度等級。你可以用它來:

  1. 評估現況:我們現在在哪裡?
  2. 設定目標:我們想達到什麼水準?
  3. 追蹤進步:我們有沒有在進步?

SAMM 五大業務功能

業務功能 說明 包含的安全實務
治理(Governance) 管理和策略 策略與指標、政策與合規、教育與指導
設計(Design) 威脅評估和架構 威脅評估、安全需求、安全架構
實作(Implementation) 建構和部署 安全建構、安全部署、缺陷管理
驗證(Verification) 測試和審查 架構評估、需求驗證測試、安全測試
營運(Operations) 事件和環境 事件管理、環境管理、營運管理

成熟度等級

等級 說明 比喻
Level 0 沒有做 房子沒裝門鎖
Level 1 有基本做法,但非系統化 有裝門鎖,但沒有保全系統
Level 2 有系統化的流程 有保全系統,定期巡邏
Level 3 全面優化,持續改進 24 小時監控中心,定期演練

快速自評 Checklist

以下是一份簡化版的 SAMM 自評表,適合作為團隊的第一次評估:

## OWASP SAMM 快速自評 Checklist

### 治理(Governance)
- [ ] Level 1: 有基本的安全政策文件
- [ ] Level 1: 團隊成員接受過安全教育訓練
- [ ] Level 2: 有定義資安 KPI 並定期追蹤
- [ ] Level 2: 安全政策每年審查更新
- [ ] Level 3: 安全策略與業務目標對齊,管理層定期審查

### 設計(Design)
- [ ] Level 1: 對高風險功能有做威脅建模
- [ ] Level 1: 有定義基本的安全需求
- [ ] Level 2: 所有新功能都有威脅建模
- [ ] Level 2: 有安全架構設計的標準和模式
- [ ] Level 3: 威脅建模結果被追蹤和驗證

### 實作(Implementation)
- [ ] Level 1: 有安全編碼規範
- [ ] Level 1: 有使用 SAST 工具
- [ ] Level 2: CI/CD 整合安全掃描
- [ ] Level 2: 有第三方套件漏洞管理流程
- [ ] Level 3: 安全品質門檻自動化,零容忍政策

### 驗證(Verification)
- [ ] Level 1: 有做過至少一次滲透測試
- [ ] Level 1: 有使用 DAST 工具
- [ ] Level 2: 定期執行安全測試(至少每季)
- [ ] Level 2: 安全測試涵蓋 OWASP Top 10
- [ ] Level 3: 安全測試完全自動化,結果追蹤到修復

### 營運(Operations)
- [ ] Level 1: 有基本的事件回應流程
- [ ] Level 1: 有安全監控和日誌
- [ ] Level 2: 有事件回應 SOP 和演練
- [ ] Level 2: 安全監控涵蓋應用層和基礎設施層
- [ ] Level 3: 自動化事件偵測和回應,定期紅隊演練

評分方式:

  • 每個 Level 的所有項目都勾選 → 達到該 Level
  • 取每個業務功能的最低達成 Level 作為該功能的分數
  • 5 個功能的平均值就是你的整體成熟度分數

八、安全儀表板設計:讓數據會說話

好的度量數據需要好的呈現方式。以下是安全儀表板的設計建議:

儀表板的三層結構

┌─────────────────────────────────────────────────────────────┐
│                    Layer 1:執行摘要                          │
│  給 CTO/CEO 看的,一眼就知道「安全嗎?」                      │
│                                                              │
│  🟢 安全態勢:良好    📊 ROSI:110%    ⚠️ 待處理:2 個高風險  │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                    Layer 2:趨勢分析                          │
│  給技術主管看的,了解「有沒有在進步?」                        │
│                                                              │
│  📈 漏洞密度趨勢(過去 4 季)                                 │
│  📉 MTTR 趨勢(過去 4 季)                                   │
│  📊 安全掃描覆蓋率趨勢                                       │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                    Layer 3:詳細數據                          │
│  給資安團隊看的,深入分析「哪裡需要改進?」                    │
│                                                              │
│  🔍 個別漏洞清單與狀態                                       │
│  📋 各 Repo 的掃描結果                                       │
│  👥 各團隊的訓練完成度                                        │
└─────────────────────────────────────────────────────────────┘

安全態勢的紅綠燈系統

用簡單的紅黃綠燈讓所有人一眼看懂:

燈號 條件 代表意義
🟢 綠燈 所有高風險漏洞已修復 + 掃描覆蓋率 > 80% + 本月零事件 安全態勢良好
🟡 黃燈 有 1-3 個高風險漏洞待修復,或覆蓋率 < 80% 需要關注
🔴 紅燈 有 > 3 個高風險漏洞,或發生 P1/P2 事件 需要立即行動

九、團隊落地建議:從零開始建立資安指標追蹤

建議一:從三個指標開始

如果你的團隊從來沒追蹤過資安指標,不要一口氣追蹤 20 個指標——那只會讓大家疲於奔命。先從這三個最基本的開始:

  1. 高風險漏洞數量(KRI)— 知道你現在有多危險
  2. 高風險漏洞 MTTR(KPI)— 知道你修得夠不夠快
  3. 安全掃描覆蓋率(KPI)— 知道你有沒有在做安全檢查

這三個指標就像體檢的血壓、血糖、心率——最基本但最關鍵。

建議二:善用現有工具自動收集

不需要另外買度量工具。你的 CI/CD 系統已經有很多數據:

資料來源 可以提取的指標
GitHub / GitLab PR 數、Code Review 數、Merge 時間
npm audit / Snyk 套件漏洞數、嚴重等級分布
Semgrep / SonarQube 程式碼漏洞數、漏洞密度
OWASP ZAP DAST 掃描結果、漏洞類別分布
Jira / Linear 漏洞修復時間、漏洞狀態追蹤
Slack / Teams 安全事件通報數、回應時間

建議三:每季做一次資安成效報告

不需要每天看數字,但至少每季做一次正式的回顧:

每季資安指標追蹤流程:

第 1 週:自動化收集數據
    ↓
第 2 週:分析趨勢,撰寫報告
    ↓
第 3 週:團隊內部 Review
    ↓
第 4 週:向管理層報告,設定下季目標
    ↓
  (下一季重複)

建議四:把度量結果跟團隊目標連結

度量不是為了懲罰,而是為了改進。把資安指標融入團隊的 OKR 或 KPI:

## 範例:工程團隊 Q2 OKR

**Objective:提升產品安全品質**

KR1:高風險漏洞 MTTR 從 14 天降至 7 天
KR2:CI/CD 安全掃描覆蓋率達 100%
KR3:漏洞逃逸率從 15% 降至 8%
KR4:全員完成 OWASP Top 10 線上課程

建議五:慶祝進步,不懲罰失敗

這是安全文化的核心。當指標改善時,公開表揚團隊。當指標退步時,把它當作學習機會,不是指責對象。

  • ✅ 「這季的 MTTR 縮短了 50%,大家做得很好!」
  • ✅ 「漏洞逃逸率上升了,我們來看看是哪個環節需要加強。」
  • ❌ 「誰寫的程式碼又出問題了?」

常見問題 FAQ

Q1:我們公司很小(3-5 人團隊),有必要追蹤資安指標嗎?

有,但可以極度簡化。小團隊只需要追蹤三個數字:目前有幾個高風險漏洞、修一個漏洞平均要多久、CI/CD 有沒有跑安全掃描。每個月花 15 分鐘看一下就好。重點不是做出一份精美的報告,而是養成「用數據看安全」的習慣。

Q2:ROSI 的數據很難估算,有什麼簡化方法?

可以用「事件成本法」來簡化:找出過去一年你們產業中類似規模公司發生資安事件的案例,估算一次事件的平均成本(包括停機、修復、客戶流失、罰鍰)。台灣中小型電商的一次資料外洩事件平均損失在 NT$200 萬到 NT$1,000 萬之間,金融業更高。拿這個數字乘以你認為的年化發生頻率(通常 0.1-0.5),就是你的 ALE。不需要追求精確,合理的估算就夠了。

Q3:安全指標一直沒改善怎麼辦?

先檢查三件事:一是指標本身是否合理——目標太高會讓團隊挫敗,建議一次只改善一個指標;二是工具是否到位——光靠人力很難提升指標,自動化工具是必要的投資;三是是否有明確的負責人——沒人負責的指標不會自己變好。如果三件事都確認了,還是沒進步,可能需要重新檢視你的安全流程設計。

Q4:管理層對資安成效報告沒興趣怎麼辦?

把報告「翻譯」成他們在意的語言。管理層不在意漏洞密度是 3.1 還是 4.2,但他們在意:「如果不處理這些漏洞,一旦發生資料外洩,可能面臨 NT$500 萬的罰鍰和客戶流失。」「這季的安全投資為公司避免了 NT$100 萬的潛在損失。」用風險和金錢說話,比用技術術語有效 10 倍。


十、結語:量化安全,讓努力被看見

回到蓋房子的比喻。你花了大量心血學習安全需求、安全設計、安全編碼、安全測試、安全監控——這一路走來,你的房子已經從一棟普通的建築,變成了一座有防震結構、消防系統、監視保全的安全堡壘。

但最終,你需要一份「驗屋報告」,證明這些努力不是白費的。資安指標就是那份報告。

它不只是給老闆看的——更是給你自己和團隊看的。當你看到漏洞密度從 10 降到 3,當你看到 MTTR 從一個月縮短到一週,當你看到安全掃描覆蓋率從 0% 爬到 100%——每一個數字的改善,都是你和團隊一步一步踏實走出來的成果。

「安全不是恐懼,而是創造的基礎。而度量,就是讓你看見自己走了多遠、還能走多遠的地圖。」

SSDLC 的七個階段,我們一路從安全需求走到了資安成效追蹤。這不是終點,而是一個新的循環的起點。有了度量,你才知道下一輪要從哪裡開始改進;有了數據,你才能持續讓安全變得更好。

記住,沒有度量的安全是盲目的努力,而有度量的安全是持續進化的力量。

讓我們用數據,讓安全的價值被每一個人看見。


延伸閱讀

[安全教育] 001 Security Champion 計畫:讓開發者成為安全推手|建立機制、培訓與激勵的完整實戰指南

「最好的防火牆,不是裝在伺服器上的那道,而是裝在每個開發者腦中的那道。
Security Champion 不是多一個頭銜,而是讓安全有了在每個團隊生根的土壤。」
— SSDLC by 飛飛


一、Security Champion 是什麼?為什麼你的團隊需要「安全種子選手」?

在 SSDLC 蓋房子的旅程中,我們已經走過了安全需求、安全設計、安全編碼、安全測試、安全部署、安全維運。到了階段七:教育與持續改進(Education & Continuous Improvement)——也就是社區防災演練、經驗傳承的階段。

但這裡有一個殘酷的現實:再好的安全制度,如果沒有「人」在團隊裡日復一日地推動,最終都會變成牆上的海報。

想像你住的社區,管委會請了一位消防顧問來演講,大家聽完拍拍手,然後繼續把滅火器當門擋用。但如果你們棟有一位鄰居,本身就住在這裡、了解大家的習慣,他會在群組裡提醒「瓦斯記得關」、在樓梯間順手檢查滅火器、住戶裝修時提醒「不要擋到消防通道」——這位鄰居就是你的 Security Champion

Security Champion(安全冠軍 / 安全倡導者) 是開發團隊中願意多學一點資安知識、在日常開發中推廣安全實踐的開發者。他們不是資安專家,也不需要轉職成資安工程師,而是在原本的開發角色上,多戴一頂安全的帽子

面向 專職資安團隊 Security Champion
身份 獨立的資安部門成員 開發團隊中的一員
比喻 消防局派來的消防員 社區裡懂消防的鄰居
優勢 專業深度、全局視野 了解團隊痛點、即時回應
劣勢 不了解每個團隊的細節 安全知識有限,需要持續學習
角色 制定政策、審計、應變 推廣實踐、第一線把關、橋樑溝通

根據 OWASP Security Champions Playbook 的定義,Security Champion 是團隊中安全事務的單一聯絡窗口,他們與資安團隊保持聯繫、監督安全最佳實踐的落實,並協助推動安全文化活動。

飛飛觀點:
在台灣,很多中小型軟體公司根本沒有專職資安人員。這不代表你可以不做安全——而是代表你更需要 Security Champion。就像小公寓沒有物管公司,更需要有個熱心鄰居幫忙注意門禁安全。


二、沒有 Security Champion 會怎樣?那些血淋淋的場景

在你決定要不要建立 Security Champion 計畫之前,先看看沒有它的團隊長什麼樣子:

場景一:資安警報變成噪音
CI/CD Pipeline 加入了 SAST 掃描(太好了,你讀了前幾篇文章!),每次掃出 20 條警告。但沒有人知道哪些重要、哪些是誤報,開發者開始習慣性忽略。三個月後,Pipeline 裡的安全掃描變成了裝飾品。

場景二:安全知識斷層
團隊裡那個懂資安的前輩離職了。他腦中的安全設計決策、為什麼密碼要用 Argon2 而不是 bcrypt、為什麼那個 API 要加 Rate Limit——全部跟著他走了。新人接手後,這些防護措施逐漸被「簡化」掉。

場景三:資安團隊變成瓶頸
公司有一個兩人的資安團隊,但他們要審全公司八個團隊的程式碼。結果?安全審查排隊排到天荒地老,開發者為了趕 deadline 直接跳過安全審查上線。

場景四:安全只在出事時被想起
平時大家各忙各的,直到某天客戶資料外洩上了新聞。老闆緊急召開會議:「誰來負責?」大家面面相覷——因為安全從來不是「誰的事」。

這些場景有沒有很眼熟?Security Champion 計畫正是為了解決這些問題而存在的。


三、Security Champion 的角色定義:他們到底要做什麼?

Security Champion 不是「兼職資安工程師」,也不是「背鍋俠」。他們的角色更像是一座橋——連結開發團隊與資安團隊,讓安全知識能在日常開發中流動。

核心職責清單

## Security Champion 職責定義

### 日常職責(每週投入 2-4 小時)
- [ ] Code Review 時關注安全面向(輸入驗證、權限檢查、錯誤處理)
- [ ] 協助團隊判讀 SAST / DAST / SCA 掃描結果
- [ ] 在 Sprint Planning 提出安全考量(「這個功能需要做威脅建模嗎?」)
- [ ] 追蹤團隊的安全技術債,確保高風險項目被排入修復計畫

### 推廣職責(每月 2-4 小時)
- [ ] 在團隊內分享最新的安全漏洞案例或技術趨勢
- [ ] 帶領團隊做安全相關的 Workshop 或 Lunch & Learn
- [ ] 維護團隊的安全 Checklist 和最佳實踐文件

### 橋樑職責(持續性)
- [ ] 作為團隊與資安團隊之間的聯絡窗口
- [ ] 將資安團隊的政策「翻譯」成開發者聽得懂的語言
- [ ] 回報團隊遇到的安全工具問題或流程卡點
- [ ] 參加跨團隊的 Security Champion 定期會議

### 進階職責(視經驗逐步加入)
- [ ] 協助進行新功能的威脅建模(STRIDE 分析)
- [ ] 撰寫安全相關的自訂 Semgrep / ESLint 規則
- [ ] 協助規劃安全驗收標準(Gherkin 格式)
- [ ] 在事件回應時提供技術支援

Security Champion ≠ 這些角色

很多團隊對 Security Champion 有誤解,這裡先釐清:

Security Champion Security Champion 不是
安全意識的推廣者 所有安全工作的執行者
團隊的安全諮詢窗口 唯一需要關心安全的人
資安團隊的延伸觸角 資安團隊的替代品
持續學習安全的開發者 已經精通資安的專家
提出安全建議的人 有權否決功能上線的人

飛飛觀點:
最常見的失敗原因是把 Security Champion 當成「免費的資安工程師」——什麼安全工作都丟給他,但不給他時間、資源和支持。這就像讓一個志工消防員去處理化學工廠大火,既不公平也不實際。


四、從零建立 Security Champion 計畫:六步驟實戰指南

第一步:取得管理層支持(最重要的一步)

沒有管理層的支持,Security Champion 計畫注定失敗。因為 Champion 需要被允許在工作時間花一部分精力在安全上。如果主管認為「寫 Code 才是正事,搞安全是浪費時間」,那 Champion 就會在產出壓力和安全責任之間被夾扁。

怎麼說服老闆?用數據和錢說話:

## 給主管的電梯簡報

### 問題
- 台灣企業每年因資安事件平均損失 NT$ 3,200 萬
- 上線後才發現的安全漏洞,修復成本是設計階段的 30-100 倍
- 我們目前沒有人在開發過程中把關安全

### 解決方案
- 建立 Security Champion 計畫
- 每個團隊指定一位開發者,每週投入 2-4 小時關注安全
- 不需要額外招人,不需要大額預算

### 預期效益
- 安全漏洞在開發階段被發現的比率提升 40-60%
- 減少上線後的安全修補成本
- 提升團隊整體安全意識與程式碼品質
- 符合客戶與法規(個資法、資安法)的安全要求

### 需要的支持
- 允許 Champion 每週有 2-4 小時的安全學習與實踐時間
- 提供基礎的培訓資源(約 NT$ 30,000-50,000 / 年)
- 在績效考核中認可 Champion 的貢獻

第二步:選對人(心態比技能重要)

Security Champion 不需要是團隊裡技術最強的人,但一定要是對安全有好奇心、願意學習、能影響他人的人。

理想的 Security Champion 特質:

  • 好奇心強:看到一個 API 會想「如果有人亂打參數會怎樣?」
  • 樂於分享:願意在團隊內分享學到的東西,不藏私
  • 溝通能力好:能把技術概念用大家聽得懂的方式說明
  • 有影響力:團隊成員信任他、願意聽他的建議
  • 主動積極:不需要人追著跑,自己會找資源學習

建議的人數比例: 每 5-10 位開發者配一位 Security Champion。太少覆蓋不了,太多反而沒人認真投入。

選人方式:

方式 優點 缺點
自願報名 動機最強、投入度高 可能沒人報名
主管推薦 可確保每個團隊都有 被推薦的人可能不情願
結合兩者 平衡覆蓋率與投入度 需要更多溝通協調

台灣場景的建議: 先從自願報名開始,搭配一場「Security Champion 是什麼」的說明會。我的經驗是,當大家知道這不是額外加班,而是有資源支持的成長機會時,通常都會有人主動舉手。

飛飛觀點:
最怕的就是「被志願」的 Champion。研究指出,被強迫擔任的 Champion 往往會心生不滿、敷衍了事。招募應該強調吸引力和好奇心,而不是強制指派。如果一個團隊真的沒人想當,那代表安全文化需要先從更基礎的地方開始培養。


第三步:建立培訓路線圖(漸進式學習)

Security Champion 不需要一開始就精通所有資安知識。好的培訓計畫是漸進式的——先打基礎,再逐步加深。

培訓路線圖(建議 6 個月為一個週期)

第 1 個月:安全意識基礎
├─ OWASP Top 10 概覽(讀完本系列文章就夠了!)
├─ 常見漏洞 Demo(SQL Injection、XSS 實際操作)
├─ 認識團隊使用的安全工具(SAST、DAST、SCA)
└─ 完成 OWASP Juice Shop 前 10 個挑戰

第 2-3 個月:工具與流程實戰
├─ 學會判讀 SonarQube / Semgrep 的掃描報告
├─ 練習做簡單的 Code Review(安全面向)
├─ 參與一次威脅建模 Workshop(STRIDE)
└─ 撰寫第一份 Abuse Case

第 4-5 個月:深化與分享
├─ 學習進階主題(認證授權設計、密碼學基礎、供應鏈安全)
├─ 在團隊內做一次 Lunch & Learn 分享
├─ 協助定義一個功能的安全驗收標準
└─ 參加外部資安社群活動或研討會

第 6 個月:評估與規劃
├─ 自我評估成長(對照初始基準)
├─ 收集團隊回饋
├─ 規劃下一期的學習重點
└─ 將經驗文件化,加入團隊知識庫

推薦的學習資源:

資源 類型 費用 適合階段
SSDLC by 飛飛 系列文章 文章 免費 第 1 個月
OWASP Juice Shop 實作平台 免費 第 1-2 個月
PortSwigger Web Security Academy 線上課程 + Lab 免費 第 2-3 個月
OWASP Security Champions Guide 指南 免費 持續參考
AWS Security Champion Knowledge Path 線上課程 + Badge 付費(Skill Builder) 第 3-5 個月
資安實務研討會(如 HITCON、CYBERSEC) 研討會 付費(NT$ 2,000-8,000) 第 4-5 個月

第四步:建立溝通機制與社群

Security Champion 不該是孤軍奮戰。他們需要一個互相支持的社群。

建議建立以下溝通管道:

1. 專屬 Slack / Teams 頻道
建一個 <code>#security-champions</code> 頻道,讓所有 Champion 可以:

  • 分享遇到的安全問題和解決方式
  • 討論掃描結果中不確定的發現
  • 轉發最新的安全資訊和漏洞通報

2. 雙週定期會議(30 分鐘)
每兩週一次的短會議,議程很簡單:

## Security Champion 雙週會議議程(30 分鐘)

1. 【5 分鐘】近期安全動態(資安團隊分享)
2. 【10 分鐘】各團隊安全近況回報
   - 有沒有遇到新的安全問題?
   - 掃描結果有什麼需要討論的?
   - 有沒有什麼安全改善的好消息?
3. 【10 分鐘】主題討論 / 案例分享
   - 輪流由一位 Champion 分享學到的東西
4. 【5 分鐘】行動項目確認

3. 知識庫(Wiki / Notion / Confluence)
集中管理所有安全相關的知識:

/security-knowledge-base
  /onboarding           ← 新進 Champion 的入門指南
  /checklists           ← 各類安全 Checklist
  /case-studies         ← 團隊遇過的安全案例
  /tool-guides          ← 安全工具使用指南
  /templates            ← 威脅建模、Abuse Case 模板
  /meeting-notes        ← 雙週會議紀錄

第五步:設計激勵機制(讓人想繼續做下去)

Security Champion 是額外付出時間和精力的角色,如果沒有適當的激勵,熱情很快就會消退。

激勵不只是獎金,而是認可、成長和影響力。

激勵類型 具體做法 成本
公開認可 在全公司月會上介紹 Champion 和他們的貢獻 免費
學習資源 贊助參加資安研討會(如 HITCON、CYBERSEC) NT$ 3,000-8,000 / 次
證照補助 補助考取 Security+ 或 CEH 等證照 NT$ 10,000-30,000
績效認可 將 Champion 工作納入績效考核的加分項目 免費
成長路徑 提供 Tech Lead 或 Security Engineer 的晉升參考 免費
季度表彰 「最佳安全倡導者」獎,附贈小禮物或獎金 NT$ 1,000-3,000
時間保障 明確規定每週有 2-4 小時的安全學習時間 免費(但最珍貴)

台灣企業的實戰做法:

  • 某台灣電商公司:每季評選「安全之星」,在公司群組公開表揚,並送一張 NT$ 3,000 的書券(買技術書籍用)
  • 某台灣金融科技新創:Security Champion 的工作納入 OKR,佔個人績效的 10%,確保主管不會忽視他們的貢獻
  • 某台灣 SaaS 公司:每年贊助一位 Champion 去參加海外資安研討會,回來後做一場公司內部分享

飛飛觀點:
在所有激勵中,「時間保障」是最重要的。很多公司口頭支持安全,但 Champion 一忙起來就被要求「先趕功能,安全的事晚點再說」。如果連學習和實踐的時間都沒有,其他激勵都是空談。


第六步:衡量成效,持續改進

Security Champion 計畫不是設立了就萬事大吉。你需要追蹤指標,確認計畫真的有在產生效果。

建議追蹤的指標:

// Security Champion 計畫成效指標
const securityChampionMetrics = {
  // 安全品質指標
  vulnerabilities: {
    '開發階段發現的漏洞數': '應該逐漸增加(代表更多問題被提前發現)',
    '上線後才發現的漏洞數': '應該逐漸減少',
    '平均漏洞修復時間': '應該逐漸縮短',
    'SAST 掃描合格率': '應該逐漸提升',
  },

  // 文化指標
  culture: {
    'Champion 活動參與率': '每月分享會的出席率',
    'Security PR Review 參與度': 'Champion 參與安全相關 Code Review 的頻率',
    '安全議題提出次數': 'Sprint Planning 中提出安全考量的次數',
    '知識庫更新頻率': '安全文件被新增或更新的次數',
  },

  // Champion 個人成長指標
  growth: {
    '培訓完成率': '預定課程的完成比例',
    '安全技能自評分數': '每季一次,1-5 分自評',
    '分享次數': 'Lunch & Learn 或文章分享的次數',
  },

  // 滿意度指標
  satisfaction: {
    'Champion 滿意度': '對計畫的整體滿意度(季度調查)',
    '團隊感受': '團隊成員是否感覺安全意識有提升',
  }
};

簡易的季度報告模板:

## Security Champion 計畫 Q_ 季度報告

### 本季重點數據
- Security Champion 人數:_ 人(覆蓋 _ 個團隊)
- 開發階段發現漏洞:_ 件(上季:_ 件)
- 上線後安全事件:_ 件(上季:_ 件)
- 平均漏洞修復時間:_ 天(上季:_ 天)

### 本季活動
- 舉辦 _ 場安全分享會
- 完成 _ 次威脅建模
- 更新 _ 份安全文件

### 成功案例
[記錄一個具體的、Champion 發現或預防安全問題的案例]

### 挑戰與改進
[記錄遇到的困難和下季改進方向]

五、台灣場景實戰:一個 20 人團隊的導入案例

讓我們用一個虛構但寫實的台灣場景,走一遍 Security Champion 計畫的完整導入。

背景

「好買網」是一間台灣的中型電商平台,團隊 20 人(3 個 Scrum 小組,每組 5-7 人),使用 Node.js + React 技術棧。沒有專職資安人員,過去安全靠上線前找外部廠商做一次滲透測試。

導入過程

Month 0:準備階段
CTO 閱讀了 SSDLC 系列文章(就是你現在在看的這個系列),決定導入 Security Champion 計畫。他向 CEO 提出提案,用「去年滲透測試發現 15 個高風險漏洞,修復花了 3 個 Sprint」的數據說服管理層支持。

Month 1:啟動

  • 舉辦一場 1 小時的全員說明會,介紹 Security Champion 計畫
  • 三個 Scrum 小組各有一人自願報名(前端組的小美、後端組的阿凱、DevOps 組的志明)
  • 每位 Champion 每週有 3 小時的安全學習時間
  • 建立 <code>#security-champions</code> Slack 頻道

Month 2-3:基礎培訓

  • 三人一起讀完 SSDLC 系列文章的安全需求和安全編碼篇
  • 各自完成 OWASP Juice Shop 前 15 個挑戰
  • 阿凱在 Code Review 時發現一個 SQL Injection 風險,提交修復 PR
  • 志明把 Semgrep 的掃描結果整理成團隊看得懂的報告格式

Month 4-6:實戰累積

  • 小美在 Sprint Planning 提出「這個新的社群分享功能需要做 Abuse Case 分析」
  • 三人合作完成了登入系統的 STRIDE 威脅建模
  • 阿凱做了第一場 Lunch & Learn:「我如何用 5 分鐘在好買網找到 XSS 漏洞」(用測試環境 demo)
  • 志明建立了團隊的「部署前安全 Checklist」
  • 外部滲透測試結果:高風險漏洞從去年的 15 個降到 4 個

Month 7-12:持續改進

  • 新進的開發者入職時,由 Champion 帶他做一次安全基礎導覽
  • 建立了安全知識庫,累積了 12 篇安全案例
  • CTO 在年度大會上公開表揚三位 Champion
  • 阿凱開始對團隊的自訂 Semgrep 規則做貢獻
  • 計畫吸引了另外兩位開發者加入,Champion 團隊擴大到 5 人

成果數據

指標 導入前 導入後(一年)
外部滲透測試高風險漏洞數 15 個 4 個
開發階段發現的安全問題 0 件 / 季 12 件 / 季
安全修復平均時間 14 天 3 天
團隊安全意識自評(1-5) 2.1 分 3.8 分
Sprint Planning 提出安全議題次數 0 次 / 月 4 次 / 月

六、Security Champion 的成熟度模型:你的計畫在哪個階段?

Security Champion 計畫不是一步到位的,它有自己的成長曲線。以下是四個成熟度等級,幫你評估目前的狀態和下一步方向:

等級 名稱 特徵 下一步行動
Level 1 萌芽期 剛指定 Champion,角色定義模糊,培訓剛開始 明確職責、建立培訓路線圖
Level 2 建立期 Champion 開始在 Code Review 中關注安全,定期會議已建立 建立激勵機制、開始量化指標
Level 3 成長期 Champion 主動發現問題、帶動團隊文化改變、有系統的知識分享 深化技能(威脅建模、自訂規則)、建立知識庫
Level 4 成熟期 安全融入開發日常、Champion 影響架構決策、形成安全社群 建立 Community of Practice、影響組織策略

根據 BSIMM(Building Security In Maturity Model)的數據,在安全成熟度最高的企業中,有 92% 設有 Security Champion 計畫,而成熟度最低的企業只有 32%。這充分說明了 Security Champion 與組織安全水準的正相關。


七、常見問題 FAQ

Q1:我們團隊只有 5 個人,也需要 Security Champion 嗎?

需要,而且更需要。小團隊沒有專職資安人員,代表安全責任完全分散,結果就是沒人負責。指定一位 Champion,哪怕每週只花 1-2 小時關注安全,都比「大家都有責任 = 沒人有責任」好太多了。

Q2:Security Champion 需要什麼背景?需要資安證照嗎?

不需要任何資安背景或證照。最重要的是對安全有興趣和好奇心。培訓計畫會逐步補足知識。事實上,很多優秀的 Champion 是從「想知道為什麼這行程式碼不安全」這個好奇心開始的。證照可以作為後續的成長目標,但絕不是門檻。

Q3:Champion 會不會變成團隊的瓶頸?什麼安全決策都要經過他?

不應該。Champion 的角色是「推廣者」和「諮詢者」,不是「審核者」。安全檢查應該盡量自動化(SAST、DAST、SCA 整合進 CI/CD),Champion 負責的是幫助團隊理解掃描結果、推動安全文化,而不是親自審每一行 Code。

Q4:如果 Champion 離職了怎麼辦?

這正是為什麼每個團隊至少要有一位 Champion、公司整體要有多位 Champion 的原因。另外,Champion 累積的知識應該被文件化在知識庫中,而不是只存在個人腦中。建議每位 Champion 培養至少一位「備援」,就像值班制度一樣。

Q5:這跟之前文章提到的「威脅建模引導者」是同一個角色嗎?

是的!在威脅建模入門那篇文章中提到的「威脅建模引導者」,就是 Security Champion 的職責之一。Champion 是一個更全面的角色,威脅建模、安全 Code Review、工具結果判讀、安全教育——這些都是 Champion 的工作範疇。


八、Security Champion Onboarding Checklist

如果你已經決定要成為或指定 Security Champion,以下是一份可以直接使用的入職清單:

## Security Champion 新手入職 Checklist

### 第一週:認識角色
- [ ] 閱讀 Security Champion 職責定義文件
- [ ] 加入 #security-champions 頻道
- [ ] 認識資安團隊的聯絡窗口
- [ ] 了解公司目前使用的安全工具(SAST、DAST、SCA)
- [ ] 閱讀 SSDLC by 飛飛 的「什麼是 SSDLC」入門文章

### 第一個月:打基礎
- [ ] 完成 OWASP Top 10 概覽學習
- [ ] 在 OWASP Juice Shop 完成前 10 個挑戰
- [ ] 學會查看 CI/CD Pipeline 中的安全掃描報告
- [ ] 在一次 Code Review 中嘗試從安全角度提出建議
- [ ] 參加第一次 Security Champion 雙週會議

### 第二個月:開始實踐
- [ ] 獨立判讀一次 SAST 掃描結果(區分真陽性和誤報)
- [ ] 在 Sprint Planning 中提出至少一個安全考量
- [ ] 閱讀 SSDLC 系列的安全需求和安全編碼文章
- [ ] 建立或更新一份團隊的安全 Checklist

### 第三個月:分享與連結
- [ ] 在團隊內做一次 15 分鐘的安全分享
- [ ] 將一個學到的安全知識寫成文件,加入知識庫
- [ ] 嘗試為一個功能撰寫 Abuse Case
- [ ] 評估自己的學習進度,規劃下一階段目標

九、結語:安全文化的最小可行單位,是一個願意多想一步的人

工具可以自動掃描、流程可以強制執行、政策可以寫得很漂亮——但真正讓安全在團隊裡生根發芽的,是

Security Champion 不是一個華麗的頭銜,而是一個簡單的承諾:「我願意在寫程式的同時,多想一步——這行 Code 安全嗎?這個設計會不會被攻擊者利用?這個錯誤訊息會不會洩露太多資訊?」

在 SSDLC 蓋房子的旅程中,我們學了怎麼寫安全需求、怎麼做威脅建模、怎麼安全編碼、怎麼做測試、怎麼強化系統。但所有這些知識,都需要有人在每一天的開發工作中去實踐。Security Champion 就是那個把知識變成行動的人。

回想一下你家社區裡那位熱心的鄰居——他不需要是消防員、不需要是保全專家,但因為他的存在,整個社區都更安全了一點。

你,願意成為你團隊的那個人嗎?

安全不是恐懼,而是創造的基礎。
當團隊裡有人願意為安全多想一步,安全文化就不再是政策文件裡的口號——而是每一次 Code Review、每一次 Sprint Planning、每一次部署中,自然而然發生的事。


延伸閱讀

[安全維運] 002 事件回應 SOP 完整指南:發生資安事件怎麼辦?NIST 四階段流程與 Node.js 實戰範例

「房子住進去之後,最怕的不是牆壁裂開,而是半夜火災時不知道滅火器在哪裡。
系統上線之後也一樣——你不怕出事,怕的是出事之後手忙腳亂、沒有人知道該做什麼。
事件回應不是『希望用不到』的東西,而是『用到時能救命』的東西。」
— SSDLC by 飛飛


一、事件回應是什麼?為什麼每個開發團隊都需要一份 SOP?

想像你住在一棟新蓋好的公寓裡。建商用了防火建材、裝了煙霧偵測器、也有消防灑水系統。這些都是之前在設計和施工階段做好的安全措施。

但有一天,三樓真的冒煙了。

這時候你需要的不是再去研究防火建材的規格,而是:

  • 誰負責打 119?
  • 住戶要從哪個樓梯逃生?
  • 消防栓在幾樓?
  • 怎麼確認所有人都安全撤離了?
  • 火滅了之後,要怎麼評估損失、修復房屋?

這就是事件回應(Incident Response, IR)——當資安事件真的發生時,你的團隊要按照什麼流程來處理,才能把傷害降到最低、盡快恢復正常。

在 SSDLC 的旅程中,我們已經走過了安全需求(確認要防火)、安全設計(畫好消防通道)、安全實作(裝好偵測器)、安全驗證(測試灑水系統能不能動)。現在我們來到了階段五:部署與維運——房子住進去之後,真的發生狀況要怎麼辦?

為什麼這件事這麼重要?

因為不管你的防禦做得多好,沒有任何系統是 100% 不會被入侵的。就像不管防火建材多好,你還是需要滅火器和逃生計畫。資安界有句名言:

「被入侵不是『會不會』的問題,而是『什麼時候』的問題。」

根據 IBM 的統計,擁有事件回應計畫並定期演練的組織,平均每次資料外洩事件可以節省超過 NT$5,000 萬的損失。不是因為他們不會被攻擊,而是因為他們知道被攻擊後該怎麼做


二、事件回應的四大階段:用火災應變來理解

事件回應有一個經典的框架,來自 NIST SP 800-61(Computer Security Incident Handling Guide)。它把事件回應分成四個階段,我們繼續用公寓火災的比喻來理解:

┌──────────────┐    ┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│  🛡️ 階段一   │───▶│  🔍 階段二   │───▶│  🔧 階段三   │───▶│  📝 階段四   │
│  準備        │    │  偵測與分析  │    │  封鎖、消除  │    │  事後活動    │
│  Preparation │    │  Detection & │    │  與復原      │    │  Post-       │
│              │    │  Analysis    │    │  Containment │    │  Incident    │
│              │    │              │    │  Eradication │    │  Activity    │
│              │    │              │    │  & Recovery  │    │              │
│ 🏠 準備好   │    │ 🏠 發現冒煙 │    │ 🏠 滅火+   │    │ 🏠 檢討+   │
│ 滅火器和     │    │ 確認是火災   │    │ 修復+      │    │ 改進         │
│ 逃生計畫     │    │ 還是燒焦味   │    │ 重新入住     │    │ 防災措施     │
└──────────────┘    └──────────────┘    └──────────────┘    └──────────────┘
        │                                                          │
        └──────────────────────────────────────────────────────────┘
                            持續改善的循環

這四個階段形成一個循環,每次處理完事件的經驗,都會回饋到準備階段,讓下一次的應變更好。


階段一:準備(Preparation)——滅火器要在火災前就買好

公寓比喻:在火災發生之前,你要做的事:

  • 買滅火器放在走廊
  • 畫好逃生路線圖貼在電梯旁
  • 確認所有住戶都知道逃生出口在哪
  • 跟管委會確認消防公司的電話
  • 每半年做一次消防演練

在資安領域,準備階段要做這些事:

1. 組建事件回應團隊(CSIRT)

CSIRT(Computer Security Incident Response Team)就是你的「消防隊」。不需要是專職的資安團隊,但每個角色必須明確:

角色 職責 公寓比喻
事件指揮官(Incident Commander) 統籌整個事件處理流程,做最終決策 管委會主委
技術負責人(Technical Lead) 帶領技術調查與修復 水電師傅
溝通聯絡人(Communications Lead) 對內對外溝通,包含通知客戶、主管機關 總幹事
法務/合規(Legal/Compliance) 確認法規通報義務、保存證據 社區法律顧問
管理層代表(Management) 核准重大決策(如關閉系統) 建設公司代表

飛飛觀點:
對於 5-10 人的小團隊,不需要每個角色都是不同的人。CTO 可以同時是事件指揮官和技術負責人,PM 可以兼溝通聯絡人。重點是事先指定,而不是事發後才在群組裡問「誰來處理?」

2. 準備工具與資源

就像滅火器要在火災前就放好,以下工具要在事件發生前就準備好:

□ 日誌集中管理系統(如 ELK Stack、Graylog)
□ 系統監控與告警工具(如 Grafana、CloudWatch)
□ 事件追蹤系統(如 Jira、PagerDuty)
□ 安全的溝通管道(不要用可能被入侵的系統來討論事件!)
□ 數位鑑識工具(如 Volatility、Autopsy)
□ 離線備份(確認備份真的可以還原)
□ 聯絡清單(團隊成員手機、外部資安廠商、主管機關聯繫方式)

3. 建立事件分類與分級標準

不是每個告警都是世界末日。你需要一套分級標準,讓團隊知道什麼時候該全員召集、什麼時候看一下就好:

等級 名稱 說明 回應時效 範例
P1 緊急(Critical) 系統核心功能受損或大規模資料外洩 15 分鐘內回應 資料庫被拖走、勒索軟體感染
P2 高(High) 部分功能受影響或有確認的入侵跡象 1 小時內回應 管理後台被未授權存取、異常大量 API 呼叫
P3 中(Medium) 可疑活動但尚未確認影響 4 小時內回應 多次登入失敗、可疑的掃描行為
P4 低(Low) 資訊性事件,需要記錄但不需緊急處理 下一個工作日 弱點掃描發現低風險問題

4. 定期演練

有逃生計畫但從來不演練,等真的火災來了,大家還是會慌。建議每季做一次桌上演練(Tabletop Exercise)——不需要真的動系統,團隊坐下來,模擬一個資安事件情境,走一遍流程。

演練情境範例(台灣電商場景):

情境:你們的電商平台在週五晚上 10 點收到客戶投訴,
說他的帳號被用來下了一筆 NT$50,000 的訂單,但他本人沒有操作。

同時,監控系統顯示過去 1 小時有超過 500 個帳號從同一個 IP 段嘗試登入,
其中 30 個帳號登入成功。

請討論:
1. 這是什麼等級的事件?
2. 誰應該被通知?
3. 第一步要做什麼?
4. 需要通報主管機關嗎?(提示:個資法)
5. 要不要暫時關閉登入功能?

階段二:偵測與分析(Detection & Analysis)——確認是真的火災,還是只是鄰居在烤肉

公寓比喻:煙霧偵測器響了,你要先搞清楚:

  • 是真的失火了嗎?還是有人在廚房燒焦了東西?
  • 如果是火災,火源在哪?幾樓?
  • 範圍多大?有擴散嗎?
  • 有沒有人受傷?

在資安領域,偵測與分析要回答這些問題:

1. 事件來源有哪些?

資安事件的「煙霧偵測器」有很多種:

偵測來源 說明 範例
監控系統告警 SIEM、IDS/IPS 發出的自動告警 「同一 IP 在 5 分鐘內嘗試登入 200 次」
使用者回報 客戶或內部員工通報 「我的帳號被盜了」「網站出現奇怪的頁面」
第三方通知 外部資安研究者或合作夥伴告知 「在暗網看到你們的客戶資料在賣」
例行檢查 定期的日誌審查或掃描 「弱點掃描發現新的 Critical 弱點」
執法機關通知 警察或調查局告知 「你們的系統IP出現在犯罪調查中」

2. 初步分析:確認事件的真實性與範圍

收到告警後,不要馬上恐慌。先做初步分析:

// 事件初步分析 Checklist(Node.js 日誌查詢範例)
const analyzeIncident = async (alertData) => {
  console.log('=== 事件初步分析 ===');

  // 1. 確認告警來源是否可信
  console.log('📌 告警來源:', alertData.source);
  console.log('📌 告警時間:', alertData.timestamp);

  // 2. 查詢相關日誌
  const relatedLogs = await queryLogs({
    timeRange: {
      start: new Date(alertData.timestamp - 3600000), // 往前看 1 小時
      end: new Date(alertData.timestamp + 1800000),   // 往後看 30 分鐘
    },
    filters: {
      sourceIP: alertData.sourceIP,
      userId: alertData.userId,
    }
  });

  // 3. 初步判斷
  const analysis = {
    isConfirmed: false,          // 是否確認為真實事件
    severity: 'UNKNOWN',         // 嚴重等級
    affectedSystems: [],         // 受影響的系統
    affectedUsers: 0,            // 受影響的使用者數量
    dataExposure: 'UNKNOWN',     // 是否有資料外洩
    isOngoing: true,             // 攻擊是否仍在進行
  };

  // 4. 記錄分析結果(保留證據!)
  await saveToIncidentLog({
    incidentId: generateIncidentId(), // IR-2026-001
    analyst: 'on-call-engineer',
    timestamp: new Date(),
    findings: analysis,
    rawLogs: relatedLogs,
  });

  return analysis;
};

3. 事件時間線建立

確認是真實事件後,最重要的事情之一就是建立事件時間線(Timeline)。這就像火災調查員會重建火災的發展過程:

📅 事件時間線範例:帳號大規模盜用事件

2026-02-27 21:30  監控系統偵測到異常登入行為
                  (同一 IP 段 103.xx.xx.0/24 大量登入嘗試)

2026-02-27 21:45  告警發送至 on-call 工程師

2026-02-27 21:50  工程師確認為 Credential Stuffing 攻擊
                  (撞庫攻擊,使用外洩的帳密清單嘗試登入)

2026-02-27 22:00  第一位客戶來電投訴帳號被盜

2026-02-27 22:10  初步統計:30 個帳號被成功登入
                  其中 5 個帳號已被用來下單

2026-02-27 22:15  升級為 P1 事件,通知事件指揮官

2026-02-27 22:20  ▶ 進入階段三:封鎖與消除

飛飛觀點:
時間線是事件回應中最有價值的產出之一。它不只是事後寫報告用的,在事件處理過程中,它能幫助你的團隊保持頭腦清醒——在壓力下,人很容易忘記自己做了什麼、什麼時間做的。養成習慣,每做一個動作就記一筆。


階段三:封鎖、消除與復原(Containment, Eradication & Recovery)——滅火、清理現場、重新入住

這是事件回應中最「動手」的階段。公寓比喻:

  • 封鎖(Containment):先阻止火勢蔓延——關閉門窗、隔離起火樓層
  • 消除(Eradication):把火撲滅、找到火源並確認完全熄滅
  • 復原(Recovery):修復受損區域、確認安全後讓住戶回來

封鎖(Containment):先止血

封鎖的目標是阻止事件繼續擴大。分為短期封鎖和長期封鎖:

類型 目的 時機 範例
短期封鎖 立即止血,減少損害 確認事件後立即執行 封鎖攻擊來源 IP、停用被入侵帳號
長期封鎖 建立臨時防線,等待根本修復 短期封鎖後 啟用額外的認證機制、部署 WAF 規則

實戰範例:處理 Credential Stuffing 攻擊的封鎖措施

// 短期封鎖措施
const shortTermContainment = async (incidentData) => {
  // 1. 封鎖攻擊來源 IP 段
  await firewall.blockIPRange('103.xx.xx.0/24');
  logAction('封鎖 IP 段 103.xx.xx.0/24');

  // 2. 強制登出所有被入侵的帳號
  for (const userId of incidentData.compromisedUsers) {
    await session.revokeAllSessions(userId);
    logAction(<code>強制登出使用者 ${userId} 所有 Session</code>);
  }

  // 3. 暫時提高登入的 Rate Limit
  await rateLimiter.update({
    endpoint: '/api/auth/login',
    maxAttempts: 3,        // 從 10 次降到 3 次
    windowMinutes: 15,     // 15 分鐘內
    blockDuration: 60,     // 超過就封鎖 60 分鐘
  });
  logAction('提高登入 Rate Limit 限制');

  // 4. 凍結可疑訂單
  for (const orderId of incidentData.suspiciousOrders) {
    await orders.freeze(orderId);
    logAction(<code class="kb-btn">凍結可疑訂單 ${orderId}</code>);
  }
};
// 長期封鎖措施
const longTermContainment = async () => {
  // 1. 對所有被入侵帳號強制重設密碼
  for (const userId of incidentData.compromisedUsers) {
    await auth.forcePasswordReset(userId);
    await notification.send(userId, {
      subject: '【重要】您的帳號安全通知',
      template: 'account-compromised',
    });
    logAction(<code>強制重設使用者 ${userId} 密碼並發送通知</code>);
  }

  // 2. 部署 WAF 規則阻擋自動化攻擊
  await waf.addRule({
    name: 'block-credential-stuffing',
    condition: 'login_failure_rate > 5/min per IP',
    action: 'CAPTCHA_CHALLENGE',
  });
  logAction('部署 WAF Credential Stuffing 防護規則');
};

消除(Eradication):把火源徹底清除

封鎖只是「止血」,消除才是「治療」。你要找到根本原因並清除:

消除階段 Checklist:

□ 根本原因已確認
  → 此案例:使用者密碼來自其他平台的外洩資料,且未啟用 MFA

□ 攻擊者的存取路徑已關閉
  → 所有被入侵帳號已重設密碼

□ 攻擊者植入的後門已清除(如有)
  → 檢查是否有新增的 API Key、OAuth App、Webhook

□ 受影響的系統已修補
  → 新增 Credential Stuffing 防護機制

□ 相關的 IOC(Indicators of Compromise)已記錄
  → 攻擊 IP 段、攻擊模式特徵

復原(Recovery):確認安全後重新開放

復原不是「打開開關就好」,而是要逐步恢復、持續監控

復原步驟:

1. ✅ 確認封鎖和消除措施都已到位
2. ✅ 在測試環境驗證修復方案
3. ✅ 逐步恢復服務(先灰度發布、再全量開放)
4. ✅ 加強監控(至少 72 小時高頻監控)
5. ✅ 確認被入侵帳號的使用者已收到通知
6. ✅ 確認可疑訂單已被正確處理(退款或確認)

飛飛觀點:
復原階段最常犯的錯誤就是「太急著恢復正常」。我見過有團隊在攻擊還沒完全消除的情況下就急著把系統打開,結果攻擊者馬上又進來了。寧可多花幾個小時確認,也不要讓同一個事件發生兩次。


階段四:事後活動(Post-Incident Activity)——火災之後的檢討與改善

火滅了、房子修好了、住戶回來了。但最重要的一步還沒做——從這次事件中學到什麼?

這個階段的核心就是無咎事後檢討會議(Blameless Postmortem)

事後檢討會議的結構

建議在事件結束後 3-5 個工作天內召開(太早大家還在善後,太晚就忘記細節了):

# 事件回顧報告
## IR-2026-001:電商平台 Credential Stuffing 攻擊事件

### 事件摘要
- 事件類型:Credential Stuffing(撞庫攻擊)
- 發生時間:2026-02-27 21:30 ~ 2026-02-28 02:00
- 持續時間:約 4.5 小時
- 嚴重等級:P1
- 影響範圍:30 個帳號被入侵,5 筆可疑訂單(總金額 NT$127,000)

### 時間線
(從偵測到復原的完整時間線,略)

### 根本原因
使用者在其他平台使用相同的帳號密碼,該平台資料外洩後,
攻擊者利用外洩的帳密清單對我們的登入 API 進行撞庫攻擊。

我們的系統缺乏:
1. 登入行為異常偵測(同 IP 大量登入嘗試未觸發告警)
2. 強制或鼓勵 MFA
3. 已知外洩密碼的比對機制

### 做得好的地方 ✅
- 監控系統在攻擊開始後 15 分鐘內發出告警
- On-call 工程師在 5 分鐘內回應
- 事件時間線記錄完整
- 客服團隊快速回應客戶詢問

### 需要改進的地方 🔧
- 告警規則的閾值太高,應該更早觸發
- 沒有預先準備好的 Credential Stuffing 應變 Playbook
- 封鎖 IP 的流程需要手動操作 AWS Console,耗時太久
- 客戶通知信的範本沒有事先準備好

### 改善行動項目
| 項目 | 負責人 | 預計完成日 | 優先順序 |
|------|--------|-----------|---------|
| 導入 MFA 並在登入頁面推廣 | 前端組 Alice | 2026-03-15 | P1 |
| 建立自動化 IP 封鎖機制 | 後端組 Bob | 2026-03-10 | P1 |
| 整合 HaveIBeenPwned API 檢查密碼是否外洩 | 後端組 Charlie | 2026-03-20 | P2 |
| 調整登入異常偵測的告警閾值 | SRE Diana | 2026-03-08 | P1 |
| 準備客戶通知信範本 | PM Eve | 2026-03-05 | P2 |
| 撰寫 Credential Stuffing Playbook | 全隊 | 2026-03-31 | P2 |

飛飛觀點:
事後檢討會議的「無咎」原則非常重要。不要問「是誰的錯」,要問「是什麼讓這件事發生了」。如果工程師怕被責備,下次發現異常就不敢回報,那才是最大的損失。


三、建立你的事件處理流程:從零到一的實戰指南

了解了四個階段之後,讓我們來建立一份實際可用的事件回應 SOP。

3.1 事件回應流程圖

                    ┌──────────────┐
                    │  偵測到告警  │
                    │  或接到通報  │
                    └──────┬───────┘
                           │
                    ┌──────▼───────┐
                    │  初步評估    │
                    │  是否為真實  │
                    │  安全事件?  │
                    └──────┬───────┘
                           │
                  ┌────────┴────────┐
                  │                 │
            ┌─────▼─────┐    ┌─────▼─────┐
            │  是:分級  │    │ 否:記錄  │
            │  並啟動    │    │ 並關閉    │
            │  回應流程  │    │           │
            └─────┬─────┘    └───────────┘
                  │
         ┌────────┼────────┐
         │        │        │
    ┌────▼───┐ ┌──▼──┐ ┌──▼────┐
    │ P1/P2  │ │ P3  │ │  P4   │
    │ 立即   │ │ 4hr │ │ 下個   │
    │ 召集   │ │ 內   │ │ 工作日 │
    │ 團隊   │ │ 回應 │ │ 處理   │
    └────┬───┘ └──┬──┘ └──┬────┘
         │        │       │
    ┌────▼────────▼───────▼────┐
    │        封鎖與止血        │
    │  • 隔離受影響系統        │
    │  • 封鎖攻擊來源          │
    │  • 保存證據              │
    └────────────┬─────────────┘
                 │
    ┌────────────▼─────────────┐
    │        消除與修復        │
    │  • 找到根本原因          │
    │  • 清除攻擊者存取        │
    │  • 修補弱點              │
    └────────────┬─────────────┘
                 │
    ┌────────────▼─────────────┐
    │        復原與監控        │
    │  • 逐步恢復服務          │
    │  • 加強監控 72 小時      │
    │  • 通知受影響使用者      │
    └────────────┬─────────────┘
                 │
    ┌────────────▼─────────────┐
    │        事後活動          │
    │  • 撰寫事件報告          │
    │  • 召開檢討會議          │
    │  • 執行改善項目          │
    │  • 法規通報(如需要)    │
    └──────────────────────────┘

3.2 事件回應 Playbook 模板

Playbook 就像是針對特定類型事件的「標準作業程序」。與其在事件發生時才想該做什麼,不如先為常見的攻擊類型寫好劇本。

以下是一個可以直接使用的 Playbook 模板:

# Playbook:[事件類型名稱]

## 適用情境
- 什麼情況下要啟動這份 Playbook

## 嚴重等級判斷
- P1 條件:...
- P2 條件:...

## 初步確認步驟
1. 檢查 [具體的日誌/監控]
2. 確認 [具體的指標]
3. 判斷 [真實性條件]

## 封鎖措施
### 立即執行(前 15 分鐘)
- [ ] 動作一
- [ ] 動作二

### 短期措施(前 1 小時)
- [ ] 動作一
- [ ] 動作二

## 消除步驟
- [ ] 找出根本原因
- [ ] 清除 [具體項目]

## 復原步驟
- [ ] 驗證修復
- [ ] 恢復服務
- [ ] 加強監控

## 通知清單
- [ ] 內部:[誰]
- [ ] 外部:[誰]
- [ ] 主管機關(如適用)

## 證據保存
- [ ] 需要保存的日誌
- [ ] 需要保存的系統快照

3.3 台灣法規通報要求

在台灣,資安事件發生後可能需要通報主管機關。這不是「做好人」,而是法律義務

法規 通報對象 通報時限 適用情況
個人資料保護法(2025 年修正) 個資保護委員會 + 當事人 發現後 72 小時內通報機關;查明後 30 日內通知當事人 個資外洩事件
資通安全管理法 主管機關 + 上級機關 1 小時內通報(依嚴重等級) 公務機關與特定非公務機關
上市櫃公司資通安全管控指引 證交所/櫃買中心 發生後儘速通報 上市櫃公司

飛飛觀點:
2025 年個資法修正後,個資外洩的通報義務變得更加嚴格。通報不是「示弱」,拖延通報才是真正的風險——不只有行政裁罰,還可能面臨民事求償。及時通報反而能展現組織的負責態度,降低後續的法律風險。


四、事件回應工具箱:實用的 Node.js 輔助工具

以下是幾個你可以整合進系統中的事件回應輔助工具範例:

4.1 安全事件自動告警

// middleware/securityMonitor.js
// 簡易的安全事件偵測中介軟體

const Redis = require('ioredis');
const redis = new Redis();

const ALERT_THRESHOLDS = {
  LOGIN_FAILURE: { count: 10, windowSeconds: 300 },   // 5 分鐘內 10 次登入失敗
  API_RATE: { count: 100, windowSeconds: 60 },         // 1 分鐘內 100 次 API 請求
  SENSITIVE_ACCESS: { count: 5, windowSeconds: 600 },  // 10 分鐘內 5 次敏感資料存取
};

async function trackSecurityEvent(eventType, identifier) {
  const key = <code class="kb-btn">security:${eventType}:${identifier}</code>;
  const threshold = ALERT_THRESHOLDS[eventType];

  if (!threshold) return;

  const count = await redis.incr(key);
  if (count === 1) {
    await redis.expire(key, threshold.windowSeconds);
  }

  if (count >= threshold.count) {
    await triggerAlert({
      type: eventType,
      identifier,
      count,
      window: threshold.windowSeconds,
      timestamp: new Date().toISOString(),
    });
  }
}

async function triggerAlert(alertData) {
  console.error('[SECURITY ALERT]', JSON.stringify(alertData));

  // 發送到 Slack
  await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: <code>🚨 安全告警:${alertData.type}\n</code> +
            <code>來源:${alertData.identifier}\n</code> +
            <code>次數:${alertData.count} 次(${alertData.window} 秒內)\n</code> +
            <code class="kb-btn">時間:${alertData.timestamp}</code>,
    }),
  });

  // 記錄到事件追蹤系統
  await saveIncidentAlert(alertData);
}

module.exports = { trackSecurityEvent };

4.2 證據保存腳本

事件發生時,第一件事就是保存證據。以下腳本可以快速收集關鍵資訊:

// scripts/collectEvidence.js
// 資安事件證據收集腳本

const fs = require('fs');
const { execSync } = require('child_process');
const path = require('path');

async function collectEvidence(incidentId) {
  const evidenceDir = path.join('/secure-evidence', incidentId);
  fs.mkdirSync(evidenceDir, { recursive: true });

  console.log(<code class="kb-btn">📁 證據收集開始:${incidentId}</code>);
  console.log(<code class="kb-btn">📂 儲存目錄:${evidenceDir}</code>);

  // 1. 收集系統日誌
  console.log('📋 收集系統日誌...');
  execSync(<code>journalctl --since "24 hours ago" > ${evidenceDir}/system.log</code>);

  // 2. 收集應用程式日誌
  console.log('📋 收集應用程式日誌...');
  execSync(<code>cp /var/log/app/*.log ${evidenceDir}/</code>);

  // 3. 收集網路連線狀態
  console.log('🌐 收集網路連線狀態...');
  execSync(<code>netstat -tlnp > ${evidenceDir}/network-connections.txt</code>);
  execSync(<code>ss -tlnp >> ${evidenceDir}/network-connections.txt</code>);

  // 4. 收集執行中的程序
  console.log('⚙️  收集程序清單...');
  execSync(<code>ps auxf > ${evidenceDir}/processes.txt</code>);

  // 5. 收集最近登入記錄
  console.log('👤 收集登入記錄...');
  execSync(<code>last -50 > ${evidenceDir}/login-history.txt</code>);
  execSync(<code>lastb -50 > ${evidenceDir}/failed-logins.txt 2>/dev/null || true</code>);

  // 6. 計算所有證據檔案的 Hash(確保證據完整性)
  console.log('🔏 計算證據 Hash...');
  const files = fs.readdirSync(evidenceDir);
  const hashes = {};
  for (const file of files) {
    const filePath = path.join(evidenceDir, file);
    const hash = execSync(<code class="kb-btn">sha256sum ${filePath}</code>).toString().trim();
    hashes[file] = hash.split(' ')[0];
  }
  fs.writeFileSync(
    path.join(evidenceDir, 'evidence-hashes.json'),
    JSON.stringify(hashes, null, 2)
  );

  console.log(<code>\n✅ 證據收集完成,共 ${files.length} 個檔案</code>);
  console.log(<code class="kb-btn">📂 存放位置:${evidenceDir}</code>);

  return evidenceDir;
}

// 使用方式
// node scripts/collectEvidence.js IR-2026-001
const incidentId = process.argv[2] || <code class="kb-btn">IR-${new Date().getFullYear()}-${Date.now()}</code>;
collectEvidence(incidentId);

五、事件回應 SOP 完整模板

以下是一份可以直接用在你的團隊的事件回應 SOP 模板:

# [公司名稱] 資安事件回應標準作業程序(SOP)

## 版本紀錄
| 版本 | 日期 | 修改人 | 修改內容 |
|------|------|--------|---------|
| 1.0 | YYYY-MM-DD | OOO | 初版制定 |

## 一、目的
定義資安事件的處理流程與責任分工,確保事件發生時能迅速、
有效地回應,將損害降到最低。

## 二、適用範圍
適用於本組織所有資訊系統、網路設備與資料資產相關的資安事件。

## 三、事件回應團隊(CSIRT)
| 角色 | 姓名 | 聯繫方式 | 備援人員 |
|------|------|---------|---------|
| 事件指揮官 | | | |
| 技術負責人 | | | |
| 溝通聯絡人 | | | |
| 法務/合規 | | | |

## 四、事件分級
(參考前文的 P1-P4 分級表)

## 五、處理流程
### 5.1 偵測與通報
- 收到告警或通報後,由 on-call 人員進行初步評估
- 判定為真實事件後,依據分級標準通知對應人員
- 建立事件追蹤單(IR-YYYY-NNN)

### 5.2 封鎖與止血
- 依據對應的 Playbook 執行封鎖措施
- 保存所有相關證據
- 記錄所有操作於事件時間線

### 5.3 消除與修復
- 找出根本原因
- 清除攻擊者存取路徑
- 修補相關弱點

### 5.4 復原
- 在測試環境驗證修復方案
- 逐步恢復服務
- 加強監控至少 72 小時

### 5.5 事後活動
- 事件結束後 3-5 個工作天內召開檢討會議
- 撰寫事件報告
- 追蹤改善項目

## 六、法規通報
- 個資外洩:72 小時內通報個資保護委員會
- 資安事件(受資安法管轄者):依等級 1 小時內通報

## 七、文件附錄
- 附錄 A:Playbook 清單
- 附錄 B:聯絡清單
- 附錄 C:證據收集指引
- 附錄 D:事件報告模板

六、團隊落地的務實建議

第一步:先有,再完善

不要試圖一次寫出完美的 SOP。先寫一份「能用」的版本,可能只有一頁——誰是 on-call、出事打什麼電話、最基本的封鎖步驟。有了這個起點,每次處理完事件後再逐步補充。

第二步:從常見攻擊開始寫 Playbook

不用一次寫完所有攻擊類型的 Playbook。建議從以下五個最常見的開始:

□ Playbook 1:帳號被入侵 / Credential Stuffing
□ Playbook 2:DDoS 攻擊
□ Playbook 3:Web 應用程式弱點被利用(SQL Injection、XSS 等)
□ Playbook 4:惡意軟體 / 勒索軟體感染
□ Playbook 5:個資外洩

第三步:每季做一次桌上演練

演練不需要動到真實系統。把團隊拉到一個會議室(或視訊),丟一個情境,走一遍流程。每次演練後記錄發現的問題,更新 SOP。

第四步:把事件回應整合進 on-call 制度

如果你的團隊有 on-call 輪值,把安全事件的回應也納入。on-call 工程師不需要會處理所有事件,但至少要會做初步評估和分級,然後知道該 call 誰。


七、常見問題 FAQ

Q1:我們是小團隊,只有 5 個人,也需要事件回應 SOP 嗎?

A:尤其需要。大公司可能有專職的資安團隊來處理事件,但小團隊發生資安事件時,影響的就是所有人。一份簡單的 SOP(即使只有一頁)可以在壓力最大的時候,幫你的團隊保持冷靜和有序。從一份簡單的聯絡清單和分級標準開始就好。

Q2:我們沒有 SIEM 或 SOC,怎麼偵測事件?

A:不需要昂貴的工具才能開始。你可以從以下低成本方案開始:

  • 應用程式日誌:確保你的 Node.js 應用程式記錄了關鍵的安全事件(登入失敗、權限錯誤等)
  • 雲端服務內建告警:AWS CloudWatch、GCP Cloud Monitoring、Azure Monitor 都有免費額度
  • 開源工具:Grafana + Loki 做日誌監控、Wazuh 做入侵偵測
  • 第三方服務:Uptime Robot(免費版本)監控網站可用性

重點不是工具有多厲害,而是你有沒有在看那些日誌。

Q3:事件發生時,要不要先關閉系統?

A:這取決於事件的性質和嚴重程度,沒有標準答案。一般原則:

  • 應該關閉:確認有持續的資料外洩、勒索軟體正在擴散、攻擊者仍在系統中且無法即時阻斷
  • 不應輕易關閉:關閉會導致證據消失(記憶體中的攻擊痕跡)、影響範圍可以透過隔離控制、關閉的損失大於事件本身的損失

這個決策應該由事件指揮官根據當下情況判斷,不要由工程師獨自決定。

Q4:怎麼跟客戶溝通資安事件?

A:透明、及時、負責任。以下是溝通的三個原則:

  • 說你知道的事實:「我們在某月某日偵測到未授權的系統存取」
  • 說你正在做什麼:「我們已經封鎖了攻擊來源,並正在全面調查影響範圍」
  • 說使用者應該做什麼:「建議您立即更改密碼,並開啟雙因素認證」
  • 不要猜測:還沒查清楚的事情不要亂講,「調查仍在進行中」是完全可以接受的說法

八、結語:最好的事件回應,是讓團隊不再害怕事件

很多團隊害怕資安事件,就像很多人害怕火災。但消防隊員不怕火災,不是因為他們覺得火燒不到自己,而是因為他們知道該怎麼做

事件回應 SOP 的真正價值,不是那份文件本身,而是準備的過程。當你的團隊一起討論「如果 XXX 發生了怎麼辦」,大家就會開始思考如何預防、如何偵測、如何回應。這個過程本身,就是在強化你的安全文化。

記住 NIST SP 800-61 的核心理念:

「事件回應不是在事件發生時才開始,而是在事件發生之前就已經在進行了。」

從今天開始,花 30 分鐘跟你的團隊坐下來,回答這三個問題:

  1. 如果我們的系統現在被入侵了,第一個被通知的人是誰?
  2. 他/她知道接下來該做什麼嗎?
  3. 我們有沒有一份寫下來的流程可以參考?

如果有任何一個答案是「不知道」或「沒有」,那就是你開始建立事件回應 SOP 的最好時機。

安全不是恐懼,而是創造的基礎。 一份好的事件回應計畫,讓你的團隊在面對資安事件時,從「恐慌」變成「有序處理」——這就是專業。


延伸閱讀

官方資源:

台灣法規:

系列文章:

[安全維運] 001 安全監控與告警:建立你的資安儀表板|關鍵安全指標監控與異常行為偵測實戰指南

「房子交屋後,不是把鑰匙交出去就沒事了。你還需要監視器、煙霧偵測器、和一個 24 小時運作的保全系統。
沒有監控的系統,就像一棟沒裝煙霧偵測器的大樓——火燒起來了,你卻是最後一個知道的人。」
— SSDLC by 飛飛


一、安全監控是什麼?為什麼你的系統需要一個「保全中心」?

在 SSDLC 蓋房子的旅程中,我們已經走過了安全需求定義(確認要防震防火防盜)、安全設計(畫好建築藍圖)、安全實作(用防火建材施工)、安全驗證(請結構技師來驗收)。現在,房子交屋了,住戶搬進去了——我們來到了階段五:部署與維運(Deployment & Operation)

想像你蓋了一棟設計完善、建材頂級、驗收通過的大樓。但交屋之後呢?如果沒有監視器,有人半夜闖入你不知道;如果沒有煙霧偵測器,五樓起火了你在一樓還在看電視;如果沒有保全系統,小偷偷走東西三天後你才發現。

安全監控,就是你的系統的「保全中心」。 它負責即時觀察系統的一切動態,在異常發生的第一時間發出警報,讓你能夠快速反應,而不是等到使用者打電話來罵、媒體報導了、或是資料已經在暗網上賣了,你才知道出事了。

在資安的世界裡,有一句老話:

「不是『會不會』被攻擊的問題,而是『什麼時候』被攻擊的問題。」

既然攻擊遲早會來,那問題就變成:你能多快發現?發現之後能多快反應? 這就是安全監控要回答的核心問題。

飛飛觀點:
我見過太多團隊把 90% 的資安預算花在「防禦」上——WAF、加密、權限控制,卻只花不到 5% 在「偵測」上。這就像花大錢裝了最頂級的門鎖,卻連監視器都沒有。門鎖遲早會被撬開,但如果你有監視器,至少能在小偷還在翻牆的時候就報警。


二、傳統做法 vs. 現代安全監控:從「事後看 Log」到「即時告警」

先來看看傳統做法和現代安全監控的差別:

面向 傳統做法 現代安全監控
監控方式 出事了才去翻 Log 檔 即時儀表板 + 自動告警
發現問題的時機 幾天甚至幾週後 幾秒到幾分鐘內
分析方法 人工 grep 搜尋 自動化規則 + 異常偵測
告警機制 沒有(靠使用者回報) Slack / Email / PagerDuty 即時通知
涵蓋範圍 只有應用程式 Log 應用程式 + 基礎設施 + 業務指標
可視性 看不到全貌,像瞎子摸象 統一儀表板,一目瞭然

用蓋房子的比喻來說:

傳統做法就像大樓管理員只在住戶投訴「我家被偷了」之後才去調監視器畫面——問題是,監視器可能根本沒開,或是錄影帶已經被覆蓋了。

現代安全監控就像有一個 24 小時的保全中心,畫面上顯示每一層樓的即時影像,煙霧偵測器連動消防系統,有人在非上班時間刷卡進入機房,警報立刻響起。


三、安全監控的三大支柱:你需要監控什麼?

安全監控不是把所有 Log 丟進一個資料庫就完事了。你需要有系統地思考「監控什麼」、「怎麼監控」、「發現異常後怎麼辦」。

支柱一:安全日誌(Security Logging)— 記錄發生了什麼

日誌是一切的基礎。沒有日誌,就像犯罪現場沒有監視器畫面——你連「發生了什麼」都說不清楚。

該記錄的事件:

事件類別 具體內容 為什麼重要
認證事件 登入成功/失敗、登出、密碼變更、MFA 驗證 偵測帳號被盜或暴力破解
授權事件 權限被拒絕的存取嘗試、角色變更、權限提升 偵測越權存取或權限提升攻擊
資料存取 敏感資料的查詢、匯出、修改、刪除 偵測資料外洩或內部威脅
系統事件 服務啟停、設定變更、部署紀錄 偵測未授權的系統修改
輸入驗證 被拒絕的輸入(XSS、SQL Injection 嘗試) 偵測攻擊探測行為
業務邏輯 異常交易、大額操作、批次操作 偵測業務層面的攻擊

不該記錄的資料:

// ❌ 絕對不要記錄這些
logger.info('User login', {
  username: 'alice',
  password: 'MyS3cret!',          // ❌ 密碼
  creditCard: '4111111111111111',  // ❌ 信用卡號
  sessionToken: 'eyJhbGciOi...',  // ❌ Session Token
  idNumber: 'A123456789',         // ❌ 身分證字號
});

// ✅ 安全的日誌記錄
logger.info('User login', {
  username: 'alice',
  status: 'success',
  ip: '203.0.113.42',
  userAgent: 'Mozilla/5.0...',
  timestamp: '2026-02-28T10:30:00Z',
  requestId: 'req-abc-123',
});

飛飛觀點:
日誌記錄有兩個極端都不好:記太少,出事了沒東西可查;記太多,敏感資料反而從日誌洩露。我見過一個案例,某公司的日誌裡記了完整的信用卡號,結果日誌伺服器被入侵,攻擊者直接從 Log 裡撈到幾萬筆信用卡資料。日誌本身也是需要保護的資產。

支柱二:關鍵安全指標(Security Metrics)— 衡量系統的健康狀態

光有日誌還不夠,你需要把日誌轉化為可量化的指標,才能知道「現在的狀況是正常的還是異常的」。

就像你去看醫生,醫生不會把你所有的血液成分都列出來,而是看幾個關鍵指標:血壓、血糖、心率。安全監控也是一樣——你需要定義幾個關鍵指標(KPI),放在儀表板上即時監控。

核心安全指標清單:

指標名稱 計算方式 正常基線 告警閾值 意義
登入失敗率 失敗次數 / 總登入次數 < 5% > 15% 可能正在被暴力破解
單一 IP 請求數 每分鐘某 IP 的請求數 < 100/min > 500/min 可能是 DDoS 或爬蟲
403 錯誤比例 403 回應 / 總回應數 < 1% > 5% 可能有人在嘗試越權存取
敏感 API 呼叫量 /api/users/export 等端點的呼叫次數 < 10/day > 50/day 可能有人在大量匯出資料
新帳號註冊速率 每小時新註冊帳號數 < 20/hr > 100/hr 可能是自動化註冊攻擊
平均回應時間 API 回應時間的 P95 < 500ms > 2000ms 可能正在被 DoS 攻擊或有效能問題
異常地理位置登入 來自非常用地區的登入次數 0 ≥ 1 帳號可能被盜用

支柱三:告警與回應(Alerting & Response)— 發現問題後怎麼辦

監控的最終目的不是「看到問題」,而是「解決問題」。一個好的告警系統應該做到:發現異常 → 通知對的人 → 提供足夠的資訊讓人判斷 → 觸發應變流程。

告警分級制度:

等級 名稱 條件範例 通知方式 回應時間
P1 緊急(Critical) 資料外洩、系統被入侵、服務全面中斷 電話 + Slack + PagerDuty 15 分鐘內
P2 嚴重(High) 大量登入失敗、可疑的資料匯出、DDoS 攻擊 Slack + Email 1 小時內
P3 中等(Medium) 異常地理位置登入、單一 IP 高頻請求 Slack 4 小時內
P4 低(Low) 少量 403 錯誤、非尖峰時段的管理者登入 Email(每日彙整) 下個工作日

飛飛觀點:
告警最怕的不是「太少」,而是「太多」。當你的 Slack 頻道每天噴出 500 則告警通知,團隊成員會開始「告警疲勞」——看到告警直接忽略,就像住在消防局旁邊的人聽到警笛聲已經無感了。寧可少但精準,也不要多但雜亂。


四、實戰:用 Node.js 建立安全監控系統

讓我們用一個台灣電商平台的場景,從頭到尾建立一套安全監控系統。

4.1 建立結構化安全日誌

第一步,是建立一個能產出結構化日誌的模組。結構化日誌(Structured Logging)意味著每一筆日誌都是可被程式解析的格式(通常是 JSON),而不是一行文字。

// security-logger.js — 安全日誌模組
const winston = require('winston');

// 定義敏感欄位,自動遮蔽
const SENSITIVE_FIELDS = [
  'password', 'creditCard', 'cardNumber', 'cvv',
  'token', 'sessionToken', 'apiKey', 'secret',
  'idNumber', 'ssn',
];

function redactSensitive(obj) {
  if (!obj || typeof obj !== 'object') return obj;

  const redacted = Array.isArray(obj) ? [...obj] : { ...obj };

  for (const key of Object.keys(redacted)) {
    if (SENSITIVE_FIELDS.some(f => key.toLowerCase().includes(f.toLowerCase()))) {
      redacted[key] = '***REDACTED***';
    } else if (typeof redacted[key] === 'object') {
      redacted[key] = redactSensitive(redacted[key]);
    }
  }

  return redacted;
}

// 建立安全日誌 Logger
const securityLogger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
    winston.format.json()
  ),
  defaultMeta: {
    service: 'ecommerce-api',
    environment: process.env.NODE_ENV || 'development',
  },
  transports: [
    // 安全事件專用的日誌檔
    new winston.transports.File({
      filename: 'logs/security.log',
      level: 'warn',
    }),
    // 所有事件的日誌檔
    new winston.transports.File({
      filename: 'logs/combined.log',
    }),
  ],
});

// 封裝安全事件記錄函式
function logSecurityEvent(eventType, severity, details) {
  const safeDetails = redactSensitive(details);

  securityLogger.log({
    level: severity === 'CRITICAL' ? 'error' : severity === 'HIGH' ? 'warn' : 'info',
    eventType,
    severity,
    ...safeDetails,
    timestamp: new Date().toISOString(),
  });
}

module.exports = { logSecurityEvent, securityLogger };

4.2 在關鍵端點加入安全日誌

// middleware/security-audit.js — 安全稽核 Middleware
const { logSecurityEvent } = require('../security-logger');

// 登入事件記錄
function logLoginAttempt(req, success, userId = null, reason = null) {
  logSecurityEvent(
    success ? 'AUTH_LOGIN_SUCCESS' : 'AUTH_LOGIN_FAILURE',
    success ? 'INFO' : 'MEDIUM',
    {
      userId,
      ip: req.ip,
      userAgent: req.get('User-Agent'),
      reason,
      path: req.path,
      method: req.method,
    }
  );
}

// 敏感操作記錄
function logSensitiveAction(req, action, target, details = {}) {
  logSecurityEvent('SENSITIVE_ACTION', 'HIGH', {
    userId: req.user?.id,
    action,       // 例如:'DATA_EXPORT', 'ROLE_CHANGE', 'PASSWORD_RESET'
    target,       // 例如:'user:12345', 'order:67890'
    ip: req.ip,
    userAgent: req.get('User-Agent'),
    ...details,
  });
}

// 存取被拒絕記錄
function logAccessDenied(req, resource, reason) {
  logSecurityEvent('ACCESS_DENIED', 'MEDIUM', {
    userId: req.user?.id,
    resource,
    reason,
    ip: req.ip,
    userAgent: req.get('User-Agent'),
    path: req.originalUrl,
    method: req.method,
  });
}

module.exports = { logLoginAttempt, logSensitiveAction, logAccessDenied };

4.3 建立異常偵測引擎

接下來,我們建立一個簡單但有效的異常偵測引擎,用來即時分析安全事件並觸發告警。

// anomaly-detector.js — 異常偵測引擎
const Redis = require('ioredis');
const redis = new Redis();

// 異常偵測規則定義
const ANOMALY_RULES = {
  // 規則一:暴力破解偵測
  BRUTE_FORCE: {
    description: '單一帳號短時間內大量登入失敗',
    window: 15 * 60,      // 15 分鐘視窗
    threshold: 5,          // 5 次失敗
    severity: 'HIGH',
    key: (event) => <code class="kb-btn">login_fail:${event.userId || event.ip}</code>,
  },

  // 規則二:帳號列舉偵測
  ACCOUNT_ENUM: {
    description: '單一 IP 嘗試大量不同帳號',
    window: 10 * 60,       // 10 分鐘視窗
    threshold: 10,         // 10 個不同帳號
    severity: 'HIGH',
    key: (event) => <code class="kb-btn">account_enum:${event.ip}</code>,
  },

  // 規則三:異常資料匯出偵測
  DATA_EXFILTRATION: {
    description: '短時間內大量匯出敏感資料',
    window: 60 * 60,       // 1 小時視窗
    threshold: 5,          // 5 次匯出
    severity: 'CRITICAL',
    key: (event) => <code class="kb-btn">data_export:${event.userId}</code>,
  },

  // 規則四:非上班時間敏感操作
  OFF_HOURS_ACCESS: {
    description: '非上班時間存取管理後台',
    window: null,          // 不需要計數視窗
    threshold: 1,
    severity: 'MEDIUM',
    check: (event) => {
      const hour = new Date().getHours();  // 使用台灣時間 (UTC+8)
      return (hour < 7 || hour > 22) && event.path?.includes('/admin');
    },
  },

  // 規則五:速率異常偵測
  RATE_ANOMALY: {
    description: '單一 IP 請求頻率異常',
    window: 60,            // 1 分鐘視窗
    threshold: 500,        // 500 次請求
    severity: 'HIGH',
    key: (event) => <code class="kb-btn">rate:${event.ip}</code>,
  },
};

async function checkAnomaly(event) {
  const alerts = [];

  for (const [ruleName, rule] of Object.entries(ANOMALY_RULES)) {
    // 特殊條件檢查(如非上班時間)
    if (rule.check && rule.check(event)) {
      alerts.push({
        rule: ruleName,
        description: rule.description,
        severity: rule.severity,
        event,
      });
      continue;
    }

    // 計數型規則
    if (rule.key) {
      const redisKey = rule.key(event);
      if (!redisKey) continue;

      const count = await redis.incr(redisKey);
      if (count === 1) {
        await redis.expire(redisKey, rule.window);
      }

      if (count >= rule.threshold) {
        alerts.push({
          rule: ruleName,
          description: rule.description,
          severity: rule.severity,
          count,
          threshold: rule.threshold,
          event,
        });
      }
    }
  }

  return alerts;
}

module.exports = { checkAnomaly, ANOMALY_RULES };

4.4 建立告警通知系統

// alerter.js — 告警通知系統
const axios = require('axios');

// Slack Webhook 通知
async function sendSlackAlert(alert) {
  const color = {
    CRITICAL: '#FF0000',
    HIGH: '#FF8C00',
    MEDIUM: '#FFD700',
    LOW: '#36A64F',
  }[alert.severity] || '#808080';

  const payload = {
    attachments: [{
      color,
      title: <code class="kb-btn">🚨 安全告警 [${alert.severity}]:${alert.rule}</code>,
      text: alert.description,
      fields: [
        { title: '觸發規則', value: alert.rule, short: true },
        { title: '嚴重等級', value: alert.severity, short: true },
        { title: '來源 IP', value: alert.event?.ip || 'N/A', short: true },
        { title: '相關使用者', value: alert.event?.userId || 'N/A', short: true },
        { title: '計數', value: <code class="kb-btn">${alert.count || 1} / ${alert.threshold || 1}</code>, short: true },
        { title: '時間', value: new Date().toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }), short: true },
      ],
      footer: 'SSDLC Security Monitor',
      ts: Math.floor(Date.now() / 1000),
    }],
  };

  try {
    await axios.post(process.env.SLACK_WEBHOOK_URL, payload);
  } catch (error) {
    console.error('Slack 通知發送失敗:', error.message);
  }
}

// Email 通知(用於 P1 級別)
async function sendEmailAlert(alert) {
  // 使用 nodemailer 或其他 Email 服務
  const nodemailer = require('nodemailer');

  const transporter = nodemailer.createTransport({
    host: process.env.SMTP_HOST,
    port: 587,
    auth: {
      user: process.env.SMTP_USER,
      pass: process.env.SMTP_PASS,
    },
  });

  await transporter.sendMail({
    from: '"Security Monitor" <security@yourcompany.com>',
    to: process.env.SECURITY_TEAM_EMAIL,
    subject: <code class="kb-btn">🚨 [${alert.severity}] 安全告警:${alert.rule}</code>,
    html: `
      <h2>安全告警通知</h2>
      <p><strong>規則:</strong>${alert.rule}</p>
      <p><strong>描述:</strong>${alert.description}</p>
      <p><strong>嚴重等級:</strong>${alert.severity}</p>
      <p><strong>來源 IP:</strong>${alert.event?.ip || 'N/A'}</p>
      <p><strong>使用者:</strong>${alert.event?.userId || 'N/A'}</p>
      <p><strong>時間:</strong>${new Date().toLocaleString('zh-TW')}</p>
      <hr>
      <p>請立即處理此安全事件。</p>
    `,
  });
}

// 統一告警分派
async function dispatchAlert(alert) {
  switch (alert.severity) {
    case 'CRITICAL':
      await sendSlackAlert(alert);
      await sendEmailAlert(alert);
      // P1 級別也可以整合 PagerDuty
      break;
    case 'HIGH':
      await sendSlackAlert(alert);
      await sendEmailAlert(alert);
      break;
    case 'MEDIUM':
      await sendSlackAlert(alert);
      break;
    case 'LOW':
      // 低級別不即時通知,累積到每日報告
      break;
  }
}

module.exports = { dispatchAlert, sendSlackAlert };

4.5 整合到 Express 應用程式

把前面的模組整合到你的 Express 應用程式中:

// app.js — 整合安全監控
const express = require('express');
const { logLoginAttempt, logSensitiveAction, logAccessDenied } = require('./middleware/security-audit');
const { checkAnomaly } = require('./anomaly-detector');
const { dispatchAlert } = require('./alerter');

const app = express();

// ===== 全域安全監控 Middleware =====
app.use(async (req, res, next) => {
  // 記錄請求開始時間
  req.startTime = Date.now();

  // 每個請求都做速率異常檢查
  const alerts = await checkAnomaly({
    ip: req.ip,
    path: req.path,
    method: req.method,
    userId: req.user?.id,
  });

  // 如果觸發告警,立即通知
  for (const alert of alerts) {
    await dispatchAlert(alert);
  }

  // 記錄回應狀態
  res.on('finish', () => {
    if (res.statusCode === 403) {
      logAccessDenied(req, req.originalUrl, 'HTTP 403 Forbidden');
    }
  });

  next();
});

// ===== 登入端點 =====
app.post('/api/auth/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    const user = await authenticate(email, password);

    if (!user) {
      // 記錄登入失敗
      logLoginAttempt(req, false, null, 'INVALID_CREDENTIALS');

      // 檢查暴力破解
      const alerts = await checkAnomaly({
        ip: req.ip,
        userId: email,
        eventType: 'LOGIN_FAILURE',
      });
      for (const alert of alerts) {
        await dispatchAlert(alert);
      }

      return res.status(401).json({ error: '帳號或密碼錯誤' });
    }

    // 記錄登入成功
    logLoginAttempt(req, true, user.id);

    // 檢查異常登入(例如新 IP、新裝置)
    const isNewLocation = await checkNewLoginLocation(user.id, req.ip);
    if (isNewLocation) {
      logSecurityEvent('NEW_LOGIN_LOCATION', 'MEDIUM', {
        userId: user.id,
        ip: req.ip,
        country: await geolocate(req.ip),
      });
    }

    const token = generateToken(user);
    res.json({ token });

  } catch (error) {
    logSecurityEvent('AUTH_ERROR', 'HIGH', { error: error.message });
    res.status(500).json({ error: '系統錯誤,請稍後再試' });
  }
});

// ===== 敏感操作端點:會員資料匯出 =====
app.get('/api/admin/users/export', authorize('admin'), async (req, res) => {
  // 記錄敏感操作
  logSensitiveAction(req, 'DATA_EXPORT', 'users', {
    format: req.query.format,
    filters: req.query.filters,
  });

  // 檢查異常匯出行為
  const alerts = await checkAnomaly({
    userId: req.user.id,
    eventType: 'DATA_EXPORT',
    ip: req.ip,
  });

  for (const alert of alerts) {
    await dispatchAlert(alert);
  }

  // 執行匯出邏輯...
});

五、建立你的資安儀表板:該看什麼?

一個好的資安儀表板,不是把所有圖表都塞上去,而是讓你「一眼就能判斷系統是否安全」。就像汽車儀表板——你不需要看到引擎每個零件的溫度,你只需要看到速度、油量、和警示燈。

5.1 儀表板架構建議

┌─────────────────────────────────────────────────────┐
│                 🔒 資安儀表板 — 總覽                  │
├──────────────────┬──────────────────────────────────┤
│  即時狀態         │  過去 24 小時趨勢                  │
│  ┌──────┐        │  📈 登入失敗率趨勢圖              │
│  │ 🟢   │ 系統   │  📈 異常請求趨勢圖                │
│  │ 正常  │ 狀態   │  📈 API 回應時間趨勢圖            │
│  └──────┘        │                                  │
│  待處理告警:3     │                                  │
│  P1: 0  P2: 1    │                                  │
│  P3: 2  P4: 5    │                                  │
├──────────────────┴──────────────────────────────────┤
│                    關鍵指標                           │
│  登入失敗率: 2.3%  │  403 錯誤: 0.4%  │  平均回應: 180ms│
│  新註冊: 15/hr    │  資料匯出: 3/day │  異常IP: 0     │
├─────────────────────────────────────────────────────┤
│              最近安全事件(最新 10 筆)                 │
│  10:32  [MEDIUM]  來自新加坡 IP 的管理者登入            │
│  10:15  [LOW]     /admin 在非上班時間被存取             │
│  09:58  [HIGH]    帳號 user@test.com 登入失敗 5 次     │
│  ...                                                 │
└─────────────────────────────────────────────────────┘

5.2 開源工具推薦

如果你的團隊不想從零開始建儀表板,以下是常見的開源方案:

工具 用途 適合場景 費用
ELK Stack(Elasticsearch + Logstash + Kibana) 日誌收集、搜尋、視覺化 中大型團隊,需要強大的搜尋能力 開源免費(付費版有額外功能)
Grafana + Loki 日誌視覺化與告警 已使用 Prometheus 的團隊 開源免費
Wazuh SIEM(安全資訊與事件管理) 需要合規要求(如 PCI DSS)的團隊 開源免費
OSSEC 主機入侵偵測系統(HIDS) 需要監控主機層級活動的場景 開源免費

給小型團隊的務實建議:

如果你是 3-5 人的小型開發團隊,不需要一開始就搞 ELK Stack。可以先用以下組合:

Winston(Node.js 日誌)
  → 輸出到 JSON 檔案
    → 用 Grafana Loki 收集
      → 在 Grafana 建立儀表板和告警規則
        → 告警發送到 Slack

這個方案簡單、免費、能在一天內搞定,就足以應付大部分小型專案的需求。


六、實戰案例:台灣電商平台的安全監控方案

場景

你的團隊負責一個台灣電商平台,日活躍用戶約 5 萬人,處理信用卡支付和個資。老闆要求你在一個月內建立基本的安全監控。

監控方案設計

第一週:基礎日誌建設

// 定義需要監控的安全事件
const SECURITY_EVENTS = {
  // 認證相關
  AUTH_LOGIN_SUCCESS: { severity: 'INFO', alert: false },
  AUTH_LOGIN_FAILURE: { severity: 'MEDIUM', alert: true },
  AUTH_PASSWORD_CHANGE: { severity: 'HIGH', alert: true },
  AUTH_MFA_FAILURE: { severity: 'HIGH', alert: true },

  // 授權相關
  ACCESS_DENIED: { severity: 'MEDIUM', alert: true },
  PRIVILEGE_ESCALATION: { severity: 'CRITICAL', alert: true },

  // 資料相關
  DATA_EXPORT: { severity: 'HIGH', alert: true },
  BULK_DATA_ACCESS: { severity: 'HIGH', alert: true },
  DATA_DELETION: { severity: 'CRITICAL', alert: true },

  // 支付相關
  PAYMENT_FAILURE: { severity: 'MEDIUM', alert: false },
  PAYMENT_ANOMALY: { severity: 'HIGH', alert: true },    // 如異常金額、頻率
  REFUND_REQUEST: { severity: 'MEDIUM', alert: false },
  BULK_REFUND: { severity: 'CRITICAL', alert: true },    // 短時間大量退款

  // 系統相關
  CONFIG_CHANGE: { severity: 'HIGH', alert: true },
  SERVICE_DOWN: { severity: 'CRITICAL', alert: true },
};

第二週:異常偵測規則

針對電商場景,設定以下偵測規則:

規則名稱 偵測邏輯 為什麼重要
暴力破解 15 分鐘內同一帳號或 IP 登入失敗 ≥ 5 次 防止帳號被盜
帳號列舉 10 分鐘內同一 IP 嘗試登入 ≥ 10 個不同帳號 攻擊者在確認有效帳號
異常支付 單一帳號 1 小時內消費金額 > NT$50,000 可能使用盜刷的信用卡
批次退款 單一管理員 1 小時內核准退款 ≥ 10 筆 可能是內部人員舞弊
大量資料匯出 24 小時內匯出用戶資料 ≥ 3 次 可能正在竊取客戶資料
非工時管理行為 深夜 23:00-06:00 有管理員登入 可能帳號被盜
地理位置異常 同一帳號 1 小時內從不同國家登入 帳號幾乎確定被盜

第三、四週:儀表板與告警

建立 Grafana 儀表板,設定以下面板:

Dashboard: 電商平台安全監控

Panel 1: 即時告警摘要(Stat Panel)
  - P1 告警數, P2 告警數, P3 告警數

Panel 2: 登入失敗率(Time Series)
  - Query: rate(login_failures[5m]) / rate(login_total[5m])
  - Alert: > 15% for 5 minutes

Panel 3: Top 10 被封鎖的 IP(Table)
  - 顯示被速率限制或暴力破解規則封鎖的 IP

Panel 4: 敏感操作時間軸(Logs Panel)
  - 過濾 severity = HIGH 或 CRITICAL 的事件

Panel 5: API 錯誤率(Time Series)
  - 4xx 和 5xx 錯誤的趨勢

Panel 6: 支付異常(Time Series)
  - 異常交易金額和退款趨勢

七、安全監控 Checklist:你的團隊可以直接用

日誌建設 Checklist

## 日誌記錄
- [ ] 所有認證事件(登入成功/失敗、登出、密碼變更)有記錄
- [ ] 所有授權失敗事件(403)有記錄
- [ ] 敏感資料存取行為有記錄
- [ ] 管理後台操作有記錄
- [ ] 系統設定變更有記錄
- [ ] 日誌中不包含密碼、Token、信用卡等敏感資料
- [ ] 日誌格式為結構化 JSON
- [ ] 每筆日誌包含:時間戳、事件類型、來源 IP、使用者 ID、請求 ID

## 日誌保存
- [ ] 日誌保存至少 6 個月(依法規要求調整)
- [ ] 日誌有備份機制
- [ ] 日誌存取有權限控制(不是所有人都能看日誌)
- [ ] 日誌完整性有保護(防止被竄改)

## 異常偵測
- [ ] 暴力破解偵測規則已設定
- [ ] 帳號列舉偵測規則已設定
- [ ] 異常存取模式偵測規則已設定
- [ ] 非上班時間敏感操作偵測規則已設定
- [ ] 異常資料匯出偵測規則已設定

## 告警與回應
- [ ] 告警分級制度已定義(P1-P4)
- [ ] P1/P2 告警有即時通知管道(Slack / Email / 電話)
- [ ] 告警通知有指定的負責人
- [ ] 告警有升級(Escalation)機制
- [ ] 定期檢視告警規則,減少誤報

## 儀表板
- [ ] 有即時安全狀態總覽
- [ ] 有關鍵安全指標趨勢圖
- [ ] 有最近安全事件清單
- [ ] 儀表板存取有權限控制

八、團隊落地建議:讓安全監控變成日常

建議一:從「三個最重要的指標」開始

不要一開始就想建一個完美的 SIEM 系統。先問自己:「如果只能看三個指標,我要看哪三個?」

對大多數 Web 應用來說,這三個最關鍵:

  1. 登入失敗率 — 偵測帳號被盜或暴力破解
  2. 403/401 錯誤趨勢 — 偵測越權存取嘗試
  3. API 回應時間 P95 — 偵測 DoS 攻擊或效能異常

先把這三個監控好、告警設好,再逐步擴展。

建議二:每週花 15 分鐘做「安全巡檢」

就像大樓管理員每天巡邏一樣,指定一位團隊成員(可以輪值),每週花 15 分鐘看一下資安儀表板:

## 每週安全巡檢報告

日期:2026/02/28
巡檢人:工程師 A

### 本週告警摘要
- P1: 0 件
- P2: 2 件(已處理)
- P3: 8 件(5 件已處理,3 件評估為誤報)

### 異常發現
- 來自越南 IP 的登入嘗試增加 30%(已加入黑名單)
- /api/users/export 被同一個管理員呼叫 12 次(已確認為正常業務需求)

### 行動項目
- [ ] 調整帳號列舉規則的閾值(目前誤報率偏高)
- [ ] 確認 AWS 帳單是否有異常(本週流量增加 15%)

建議三:把監控和事件回應串起來

監控只是偵測,偵測到之後需要有標準的處理流程。建議建立一個簡單的事件回應 SOP:

發現異常
  │
  ├─ P1(緊急)→ 立即通知資安負責人 → 15 分鐘內開始處理
  │                → 同步通知主管和相關人員
  │
  ├─ P2(嚴重)→ Slack 通知 → 1 小時內開始調查
  │                → 記錄在事件追蹤系統
  │
  ├─ P3(中等)→ Slack 通知 → 4 小時內評估
  │                → 判斷是否為誤報
  │
  └─ P4(低)  → 每日彙整報告 → 下個工作日處理

建議四:別忘了監控「監控系統本身」

這聽起來很像雞生蛋蛋生雞的問題,但非常重要。如果你的日誌系統掛了、告警通知沒送出去,你根本不知道自己「瞎了」。

簡單的做法:設定一個「心跳檢查」,每 5 分鐘讓監控系統送一個測試告警到你的 Slack。如果超過 10 分鐘沒收到心跳,代表監控系統本身可能有問題。


九、與台灣法規的連結

安全監控不只是技術需求,也是法規要求。在前面的法規遵循指南中,我們提到:

法規 監控相關要求
個資法 建立個資檔案安全維護計畫,包含「事故之預防、通報及應變機制」
資安法 資安事件須在 1~72 小時內通報(依等級),系統必須有偵測與告警能力
金融資安行動方案 核心系統須有即時告警,資安事件須在 30 分鐘~24 小時內通報
上市櫃資安管控指引 須建立資安事件偵測、告警與通報機制

沒有監控,你連「有沒有發生資安事件」都不知道——更別說在規定時間內通報了。


常見問題 FAQ

Q1:小團隊預算有限,用什麼方案最務實?

Winston(日誌)+ Redis(計數器)+ Slack Webhook(告警) 這個組合,幾乎零成本就能建立基本的安全監控。不需要 ELK、不需要 Splunk。等團隊規模和流量長大了,再考慮升級到 Grafana + Loki 或 ELK Stack。先有比完美更重要。

Q2:告警太多,團隊已經「告警疲勞」了怎麼辦?

這是很常見的問題。解決方法有三個:一是提高告警閾值,寧可漏掉一些低風險的,也不要淹沒在噪音裡;二是做告警聚合,同一種類型的告警在 5 分鐘內只通知一次,附上數量統計;三是定期清理規則,每個月檢視一次,把過去 30 天裡「收到之後從來沒有需要處理」的告警規則降級或移除。

Q3:要監控到什麼程度才算「夠」?

沒有標準答案,但可以用這個檢驗標準:如果現在有人在竊取你的使用者資料,你能在多久內發現? 如果答案是「不知道」或「幾天後」,那監控絕對不夠。目標是把這個時間縮短到「幾分鐘到幾小時內」。IBM 的年度報告統計,全球企業平均要 194 天才能發現資料外洩——你不會想成為這個統計數字的一部分。

Q4:日誌要保存多久?

台灣法規的最低要求通常是 6 個月,但建議保存至少 1 年。如果你是金融業或處理大量個資,考慮保存 2-3 年。日誌存儲的成本遠低於事後無法調查的代價。


十、結語:看見,才能保護

回到蓋房子的比喻。你花了大量心血設計防震結構、選用防火建材、安裝堅固門鎖。但如果沒有煙霧偵測器,一場小火可能在你睡夢中燒毀一切;如果沒有監視器,小偷可以從容地搬走你的家當。

安全監控就是你系統的「眼睛」和「耳朵」。它不會阻止攻擊發生,但它能確保你在第一時間知道、第一時間反應。

「安全不是恐懼,而是創造的基礎。而監控,就是讓你安心創造的守護者。」

下一篇,我們將進入事件回應 SOP——當告警真的響起來了,你和你的團隊該怎麼辦?別擔心,有了監控的基礎,你已經贏在起跑點了。


延伸閱讀

[安全部署] 001 系統強化 Hardening 入門指南:關閉不必要的門窗|CIS Benchmark 應用與容器、雲端環境強化實戰

「房子蓋好了、驗收也過了,但你有檢查過哪些窗戶還開著嗎?系統強化,就是交屋後的最後一道安全流程。」
— SSDLC by 飛飛


為什麼你需要系統強化?蓋好房子不等於住進去就安全

在 SSDLC 的學習旅程中,我們已經走過了安全需求定義、安全設計、安全編碼,也做了 SAST、DAST、CI/CD 安全整合,甚至請人做了滲透測試。現在,系統終於要上線了——恭喜你來到了階段五:部署

但先等一下。你的程式碼再安全、測試再完整,如果跑在一台設定不當的伺服器上,那一切都白費了

延續蓋房子的比喻:你花了大錢請建築師畫防震設計圖、用了最好的防火建材、裝了頂級保全系統。但交屋那天,你發現——後門沒鎖、地下室的窗戶大開、備用鑰匙還掛在門口、Wi-Fi 密碼是 <code>12345678</code>。

這就是「系統強化(System Hardening)」要解決的問題:把所有不必要的門窗關上、把預設的弱設定改掉、把用不到的服務停掉

一個真實的痛點

2021 年,台灣某知名論壇因為伺服器的 Apache Struts 未即時更新,被駭客利用已知漏洞入侵,導致數百萬筆會員資料外洩。問題不在程式碼,而在運行環境的設定與維護

類似的案例層出不窮:AWS S3 Bucket 設成公開、Docker 容器用 root 跑服務、資料庫預設帳號密碼沒改、SSH 允許 root 遠端登入⋯⋯這些都不是程式邏輯的漏洞,而是系統設定的疏忽

飛飛觀點:
很多開發者覺得「部署是 DevOps 的事、設定是 SRE 的事」。但在 SSDLC 的理念中,安全是每個人的責任。你寫的程式跑在什麼環境上、那個環境夠不夠安全,這直接影響你的程式碼能不能真正保護使用者。


什麼是系統強化(Hardening)?把「預設不安全」變成「預設安全」

系統強化,簡單來說就是:減少系統的攻擊面(Attack Surface)

每一台伺服器、每一個容器、每一個雲端服務,出廠時都會帶著一堆預設設定。這些預設設定的設計目標是「讓你能快速上手」,而不是「讓你最安全」。所以你會看到:

  • 作業系統預裝了一堆你用不到的服務(FTP、Telnet、SNMP)
  • Web 伺服器預設開啟目錄列表(Directory Listing)
  • 資料庫預設允許遠端連線且帳號密碼是 <code>admin/admin</code>
  • Docker 容器預設用 root 執行
  • 雲端服務的 Security Group 預設允許 0.0.0.0/0 入站

系統強化就是有系統性地把這些「門窗」一扇扇關上,只留下你真正需要的。

系統強化 vs. 安全設計原則的關係

還記得我們在安全設計階段學的七大原則嗎?系統強化其實就是這些原則在維運階段的具體實踐:

安全設計原則 在系統強化中的應用
最小權限 服務帳號只給需要的權限、容器不用 root 跑
預設安全 關閉不必要的服務和 Port、移除預設帳號
縱深防禦 網路層 + 主機層 + 應用層多層設定
最小共享 每個服務獨立隔離、不共用帳號和憑證
失敗安全 防火牆預設拒絕、只開白名單

CIS Benchmark 是什麼?你的系統強化教科書

自己從零開始想「該關哪些門窗」太費時了,而且很容易漏掉。好在,有人已經幫你整理好了——這就是 CIS Benchmark

CIS(Center for Internet Security,網際網路安全中心) 是一個非營利組織,集結了全球數千位資安專家,針對各種作業系統、雲端平台、容器、資料庫、網路設備,制定了詳細的安全設定建議

用蓋房子比喻:CIS Benchmark 就像「建築安全規範手冊」。你不需要自己去研究鋼筋要多粗、消防通道要多寬——規範手冊都寫好了,你照著做就好。

CIS Benchmark 的七大類別

類別 涵蓋範圍 常見範例
作業系統 Windows、Linux、macOS Ubuntu 24.04、Windows Server 2022、RHEL 9
伺服器軟體 Web 伺服器、資料庫 Nginx、Apache、PostgreSQL、MySQL
雲端平台 三大雲的基礎設定 AWS Foundations、Azure Foundations、GCP
容器與編排 容器化環境 Docker、Kubernetes
網路設備 防火牆、交換器 Cisco、Palo Alto、Fortinet
桌面軟體 瀏覽器、辦公軟體 Chrome、Firefox、Microsoft 365
行動裝置 手機作業系統 iOS、Android

兩個安全等級:Level 1 與 Level 2

CIS Benchmark 把建議分成兩個等級,你可以根據自己的風險承受度來選擇:

等級 說明 適合對象 對系統的影響
Level 1 基本安全建議,適用於所有環境 所有系統,尤其是剛開始做強化的團隊 低,幾乎不影響功能
Level 2 進階安全建議,提供更高的防護 處理敏感資料的系統(如金融、醫療) 中,可能需要調整部分功能

飛飛觀點:
如果你的團隊是第一次做系統強化,先從 Level 1 開始就好。不要一開始就追求 Level 2 全部滿分——那就像新手健身第一天就舉 100 公斤,只會受傷。先把 Level 1 做扎實,再逐步升級。

如何取得 CIS Benchmark?

CIS Benchmark 是免費下載的(PDF 格式),你只需要到 CIS 官網註冊帳號即可:

  1. 前往 https://www.cisecurity.org/cis-benchmarks
  2. 選擇你需要的技術類別(如 Linux、Docker、AWS)
  3. 填寫基本資訊後即可下載 PDF

實戰:Node.js 應用常見的系統強化項目

讓我們回到 SSDLC by 飛飛系列一直使用的技術棧——Node.js + Express,看看在部署時有哪些系統強化的工作要做。

一、作業系統層級強化(以 Ubuntu 為例)

# ═══════════════════════════════════════
# 1. 更新系統,修補已知漏洞
# ═══════════════════════════════════════
sudo apt update && sudo apt upgrade -y

# 啟用自動安全更新(就像設定自動繳管理費,不會忘記)
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure --priority=low unattended-upgrades

# ═══════════════════════════════════════
# 2. 移除不必要的服務(關閉不需要的門窗)
# ═══════════════════════════════════════
# 列出目前啟用的服務
systemctl list-unit-files --type=service --state=enabled

# 停用並關閉不需要的服務
sudo systemctl stop avahi-daemon && sudo systemctl disable avahi-daemon   # mDNS 服務
sudo systemctl stop cups && sudo systemctl disable cups                     # 列印服務
sudo systemctl stop rpcbind && sudo systemctl disable rpcbind               # NFS 相關

# ═══════════════════════════════════════
# 3. 設定防火牆(只開需要的 Port)
# ═══════════════════════════════════════
# 使用 UFW(Uncomplicated Firewall)
sudo ufw default deny incoming    # 預設拒絕所有入站(失敗安全原則)
sudo ufw default allow outgoing   # 允許出站
sudo ufw allow 22/tcp             # SSH(建議改用非標準 Port)
sudo ufw allow 443/tcp            # HTTPS
sudo ufw allow 80/tcp             # HTTP(建議只用於重導到 HTTPS)
sudo ufw enable

# ═══════════════════════════════════════
# 4. SSH 強化(鎖好遠端登入的大門)
# ═══════════════════════════════════════
sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup
# /etc/ssh/sshd_config 安全設定
# 參考 CIS Benchmark for Ubuntu — SSH Server Configuration

Port 2222                          # 改用非標準 Port,減少自動化掃描
PermitRootLogin no                 # 禁止 root 直接登入
PasswordAuthentication no          # 禁用密碼登入,只允許 SSH Key
PubkeyAuthentication yes           # 啟用公鑰認證
MaxAuthTries 3                     # 最多嘗試 3 次
ClientAliveInterval 300            # 5 分鐘無活動斷開
ClientAliveCountMax 2              # 最多容忍 2 次無回應
AllowUsers deploy                  # 只允許特定使用者登入
Protocol 2                         # 只用 SSH Protocol 2
X11Forwarding no                   # 關閉 X11 轉發
# 重啟 SSH 服務讓設定生效
sudo systemctl restart sshd

# ═══════════════════════════════════════
# 5. 建立專用服務帳號(最小權限原則)
# ═══════════════════════════════════════
# 建立一個沒有 shell 的專用帳號跑 Node.js
sudo useradd --system --no-create-home --shell /usr/sbin/nologin nodeapp

二、Node.js 與 Express 應用層級強化

// app.js — 生產環境的安全設定
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');

const app = express();

// ═══════════════════════════════════════
// 1. 使用 Helmet 設定安全標頭
// ═══════════════════════════════════════
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'"],
      fontSrc: ["'self'"],
      objectSrc: ["'none'"],
      mediaSrc: ["'self'"],
      frameSrc: ["'none'"],
    },
  },
  crossOriginEmbedderPolicy: true,
  crossOriginOpenerPolicy: true,
  crossOriginResourcePolicy: { policy: "same-site" },
  dnsPrefetchControl: true,
  frameguard: { action: 'deny' },
  hidePoweredBy: true,                     // ✅ 移除 X-Powered-By
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
  ieNoOpen: true,
  noSniff: true,
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  xssFilter: true,
}));

// ═══════════════════════════════════════
// 2. 速率限制(防止暴力攻擊與 DDoS)
// ═══════════════════════════════════════
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,   // 15 分鐘
  max: 100,                     // 每個 IP 最多 100 次請求
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: '請求過於頻繁,請稍後再試' }
});
app.use('/api/', limiter);

// ═══════════════════════════════════════
// 3. 關閉不必要的功能
// ═══════════════════════════════════════
app.disable('x-powered-by');           // 不洩露技術棧
app.disable('etag');                   // 視情況關閉(防止資訊洩露)

// ═══════════════════════════════════════
// 4. 安全的錯誤處理(不洩露內部資訊)
// ═══════════════════════════════════════
if (process.env.NODE_ENV === 'production') {
  app.use((err, req, res, next) => {
    console.error(<code>[${new Date().toISOString()}] Error:</code>, {
      message: err.message,
      stack: err.stack,        // 只記到伺服器日誌
      url: req.originalUrl,
      method: req.method,
      ip: req.ip
    });

    res.status(err.status || 500).json({
      error: '系統處理時發生錯誤,請稍後再試'
      // ❌ 絕不回傳 err.stack 或 err.message
    });
  });
}

// ═══════════════════════════════════════
// 5. 只監聽必要的介面
// ═══════════════════════════════════════
const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '127.0.0.1';  // ✅ 只監聽 localhost
// 外部流量由 Nginx 反向代理進來,Node.js 不直接暴露

app.listen(PORT, HOST, () => {
  console.log(<code class="kb-btn">Server running on ${HOST}:${PORT}</code>);
});

三、Nginx 反向代理強化

# /etc/nginx/conf.d/security.conf — Nginx 安全設定
# 參考 CIS Benchmark for Nginx

# 隱藏版本號(不讓攻擊者知道你用什麼版本)
server_tokens off;

# 限制請求大小(防止大檔案攻擊)
client_max_body_size 10M;
client_body_buffer_size 1K;
client_header_buffer_size 1K;
large_client_header_buffers 2 1K;

# 超時設定(防止慢速攻擊 Slowloris)
client_body_timeout 10;
client_header_timeout 10;
keepalive_timeout 15;
send_timeout 10;

# 安全標頭(與 Helmet 互補)
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

# 只允許安全的 TLS 版本和加密套件
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;

# 禁用不安全的 HTTP 方法
if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE|PATCH)$ ) {
    return 405;
}
# /etc/nginx/sites-available/myapp.conf
server {
    listen 80;
    server_name myapp.com.tw;
    # 強制導向 HTTPS
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name myapp.com.tw;

    ssl_certificate /etc/letsencrypt/live/myapp.com.tw/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/myapp.com.tw/privkey.pem;

    # HSTS — 告訴瀏覽器永遠用 HTTPS
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

    location /api/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 隱藏後端的 Header
        proxy_hide_header X-Powered-By;
    }

    # 禁止存取隱藏檔案(.env, .git 等)
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

容器環境強化:Docker 不是天生安全的

容器化部署是現代應用的標配,但 Docker 的預設設定有不少安全隱患。很多開發者以為「放進容器就是隔離了」,但如果容器裡跑的是 root、映像裡藏著漏洞、網路沒有隔離——那容器只是一層薄薄的保鮮膜,不是防彈衣。

Dockerfile 安全最佳實踐

# ═══════════════════════════════════════
# 安全的 Node.js Dockerfile
# 參考 CIS Docker Benchmark
# ═══════════════════════════════════════

# 1. 使用特定版本的精簡映像(不用 latest,不用完整版)
FROM node:20.11-alpine AS builder

WORKDIR /app

# 2. 只複製 package 檔案,利用 Docker 快取
COPY package.json package-lock.json ./

# 3. 使用 npm ci(確保依賴與 lock 檔一致)
RUN npm ci --only=production && npm cache clean --force

# ─── 第二階段:只帶需要的東西 ───
FROM node:20.11-alpine

# 4. 加入安全更新
RUN apk update && apk upgrade --no-cache && rm -rf /var/cache/apk/*

# 5. 建立非 root 使用者(最小權限原則)
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app

# 6. 從 builder 階段複製依賴
COPY --from=builder /app/node_modules ./node_modules
COPY . .

# 7. 設定檔案權限
RUN chown -R appuser:appgroup /app

# 8. 切換到非 root 使用者
USER appuser

# 9. 只暴露需要的 Port
EXPOSE 3000

# 10. 使用 dumb-init 或 tini 處理信號(避免殭屍程序)
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]

# 11. 健康檢查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "server.js"]

Docker Compose 安全設定

# docker-compose.yml — 安全設定範例
version: '3.8'

services:
  app:
    build: .
    ports:
      - "127.0.0.1:3000:3000"   # ✅ 只綁定 localhost,不暴露到外網
    environment:
      - NODE_ENV=production
    # ═══ 安全設定 ═══
    read_only: true               # ✅ 唯讀檔案系統
    tmpfs:
      - /tmp                      # 只有 /tmp 可寫入
    security_opt:
      - no-new-privileges:true    # ✅ 禁止提權
    cap_drop:
      - ALL                       # ✅ 移除所有 Linux Capabilities
    cap_add:
      - NET_BIND_SERVICE          # 只加回需要的(如果要綁 Port < 1024)
    deploy:
      resources:
        limits:
          cpus: '1.0'             # ✅ 限制 CPU 使用量
          memory: 512M            # ✅ 限制記憶體
    networks:
      - frontend                  # ✅ 放在專屬網路

  db:
    image: postgres:16-alpine
    volumes:
      - db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password  # ✅ 用 Secret 管理密碼
    networks:
      - backend                   # ✅ 資料庫在獨立網路

# ═══ 網路隔離(最小共享原則)═══
networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true                # ✅ 後端網路不連外網

volumes:
  db-data:

secrets:
  db_password:
    file: ./secrets/db_password.txt

容器映像掃描:上線前的健康檢查

# 使用 Trivy 掃描容器映像中的漏洞
# 安裝 Trivy
# macOS
brew install trivy

# Linux
sudo apt-get install trivy

# 掃描你的映像
trivy image my-app:latest

# 只顯示高風險和嚴重漏洞
trivy image --severity HIGH,CRITICAL my-app:latest

# 整合進 CI/CD(發現嚴重漏洞就失敗)
trivy image --exit-code 1 --severity CRITICAL my-app:latest

飛飛觀點:
容器安全最常被忽略的三件事:用 root 跑、用 latest 標籤、不掃描映像。只要解決這三個問題,你的容器安全就已經贏過大多數人了。


雲端環境強化:AWS / GCP / Azure 的安全設定

如果你的應用部署在雲端,除了伺服器本身,還有一整層「雲端服務設定」需要強化。CIS 針對三大雲端平台都有專門的 Benchmark,以下用 AWS 為例說明最關鍵的設定。

AWS 常見的安全設定問題

問題 風險等級 正確做法
S3 Bucket 設為公開 🔴 嚴重 預設私有,需要公開的用 CloudFront 分發
IAM 使用者用 root 帳號操作 🔴 嚴重 建立 IAM 使用者,root 只用於帳號管理
Security Group 允許 0.0.0.0/0 SSH 🔴 嚴重 只允許特定 IP 或 VPN 的 CIDR 範圍
未啟用 CloudTrail 稽核日誌 🟡 高風險 啟用 CloudTrail 並送到獨立的 S3 Bucket
未啟用 MFA 🟡 高風險 所有 IAM 使用者強制 MFA
EBS 未加密 🟡 高風險 預設啟用 EBS 加密
RDS 可公開存取 🔴 嚴重 放在 Private Subnet,不開放公開存取

以 Terraform 實作安全的 AWS 基礎設施

# main.tf — 安全的 AWS 基礎設施設定
# 參考 CIS AWS Foundations Benchmark

# ═══════════════════════════════════════
# 1. S3 Bucket 安全設定
# ═══════════════════════════════════════
resource "aws_s3_bucket" "app_data" {
  bucket = "myapp-data-tw"
}

# ✅ 封鎖所有公開存取
resource "aws_s3_bucket_public_access_block" "app_data" {
  bucket = aws_s3_bucket.app_data.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# ✅ 啟用伺服器端加密
resource "aws_s3_bucket_server_side_encryption_configuration" "app_data" {
  bucket = aws_s3_bucket.app_data.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
    bucket_key_enabled = true
  }
}

# ✅ 啟用版本控制(防止意外刪除)
resource "aws_s3_bucket_versioning" "app_data" {
  bucket = aws_s3_bucket.app_data.id
  versioning_configuration {
    status = "Enabled"
  }
}

# ═══════════════════════════════════════
# 2. Security Group — 最小開放原則
# ═══════════════════════════════════════
resource "aws_security_group" "app_sg" {
  name        = "app-security-group"
  description = "Application security group"
  vpc_id      = aws_vpc.main.id

  # ✅ 只允許 HTTPS 入站
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow HTTPS from anywhere"
  }

  # ✅ SSH 只允許公司 VPN IP
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["203.0.113.0/24"]   # 公司 VPN IP 範圍
    description = "Allow SSH from company VPN only"
  }

  # ✅ 出站只允許必要的
  egress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow HTTPS outbound"
  }

  egress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = [aws_subnet.private.cidr_block]
    description = "Allow PostgreSQL to private subnet"
  }

  tags = {
    Name = "app-sg"
  }
}

# ═══════════════════════════════════════
# 3. RDS — 資料庫安全設定
# ═══════════════════════════════════════
resource "aws_db_instance" "main" {
  identifier     = "myapp-db"
  engine         = "postgres"
  engine_version = "16.1"
  instance_class = "db.t3.medium"

  # ✅ 不可公開存取
  publicly_accessible = false

  # ✅ 啟用加密
  storage_encrypted = true

  # ✅ 啟用自動備份
  backup_retention_period = 7

  # ✅ 啟用刪除保護
  deletion_protection = true

  # ✅ 放在 Private Subnet
  db_subnet_group_name = aws_db_subnet_group.private.name

  # ✅ 啟用效能監控
  monitoring_interval = 60
}

系統強化 Checklist:你的部署前檢查清單

每次部署前,用這份 Checklist 確認系統強化的項目是否都有照顧到:

## 系統強化部署前 Checklist

### 作業系統層級
- [ ] 系統已更新至最新安全版本?
- [ ] 自動安全更新已啟用?
- [ ] 不必要的服務已停用?(FTP、Telnet、SNMP、CUPS 等)
- [ ] 防火牆已設定,預設拒絕入站?
- [ ] SSH 已禁用 root 登入、禁用密碼認證?
- [ ] 應用程式使用專用的非 root 帳號執行?
- [ ] 檔案系統權限已適當設定?(敏感設定檔權限 600/640)

### 應用程式層級
- [ ] X-Powered-By Header 已移除?
- [ ] Debug 模式已關閉?
- [ ] 錯誤訊息不洩露內部資訊?
- [ ] HTTP 安全標頭已設定?(CSP、HSTS、X-Frame-Options 等)
- [ ] CORS 只允許白名單域名?
- [ ] 速率限制已啟用?
- [ ] TLS 1.2 以上且使用安全的加密套件?

### 容器環境
- [ ] 容器不以 root 使用者執行?
- [ ] 使用特定版本的映像(非 latest)?
- [ ] 映像已通過漏洞掃描(Trivy / Snyk)?
- [ ] 使用多階段建置減少映像大小?
- [ ] 檔案系統設為唯讀?(read_only: true)
- [ ] Linux Capabilities 已最小化?(cap_drop: ALL)
- [ ] 容器間的網路已適當隔離?
- [ ] 健康檢查已設定?

### 雲端環境
- [ ] S3 / Storage 封鎖公開存取?
- [ ] IAM 權限遵循最小權限原則?
- [ ] Security Group 不允許 0.0.0.0/0 SSH?
- [ ] 資料庫不可公開存取?
- [ ] CloudTrail / 稽核日誌已啟用?
- [ ] 所有儲存已啟用加密?
- [ ] MFA 已對所有使用者啟用?
- [ ] 定期審查 IAM 權限和安全設定?

自動化強化:讓工具幫你檢查

手動逐條檢查 CIS Benchmark 太費時了。好消息是,有很多工具可以幫你自動化這個流程:

工具 用途 費用 適合場景
CIS-CAT Pro 依據 CIS Benchmark 自動評估 會員制 企業級合規需求
Trivy 容器映像 + IaC 漏洞掃描 免費開源 容器和 Kubernetes
Lynis Linux 系統安全審計 免費(社群版) 伺服器強化檢查
ScoutSuite 多雲安全設定審計 免費開源 AWS / Azure / GCP
Docker Bench Docker 安全最佳實踐檢查 免費開源 Docker 環境
kube-bench Kubernetes CIS Benchmark 檢查 免費開源 Kubernetes 環境
Prowler AWS 安全最佳實踐檢查 免費開源 AWS 環境

快速使用範例

# ═══ Lynis:Linux 系統安全審計 ═══
# 安裝
sudo apt install lynis -y

# 執行完整系統審計
sudo lynis audit system

# 結果會顯示:
# - 安全評分(Hardening Index)
# - 需要修復的項目
# - 建議的改善措施

# ═══ Docker Bench:Docker 安全檢查 ═══
# 直接用 Docker 跑
docker run --rm --net host --pid host \
  --userns host --cap-add audit_control \
  -e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
  -v /var/lib:/var/lib:ro \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -v /etc:/etc:ro \
  docker/docker-bench-security

# ═══ Prowler:AWS 安全檢查 ═══
# 安裝
pip install prowler --break-system-packages

# 執行 CIS AWS Foundations Benchmark 檢查
prowler aws --compliance cis_2.0_aws

整合進 CI/CD Pipeline

還記得我們在前幾篇設定的安全 Pipeline 嗎?現在可以把系統強化的檢查也加進去:

# .github/workflows/security.yml — 加入容器和 IaC 掃描
  # ═══════════════════════════════════════
  # 容器映像安全掃描
  # ═══════════════════════════════════════
  container-hardening:
    name: 🐳 容器安全強化檢查
    runs-on: ubuntu-latest
    if: hashFiles('Dockerfile') != ''
    steps:
      - uses: actions/checkout@v4

      - name: 建置 Docker 映像
        run: docker build -t my-app:scan .

      - name: Trivy 容器漏洞掃描
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'my-app:scan'
          format: 'table'
          exit-code: '1'
          severity: 'HIGH,CRITICAL'
          ignore-unfixed: true

      - name: Dockerfile 最佳實踐檢查(Hadolint)
        uses: hadolint/hadolint-action@v3.1.0
        with:
          dockerfile: Dockerfile

  # ═══════════════════════════════════════
  # 基礎設施即程式碼(IaC)安全掃描
  # ═══════════════════════════════════════
  iac-scan:
    name: 🏗️ 基礎設施安全掃描
    runs-on: ubuntu-latest
    if: hashFiles('**/*.tf') != '' || hashFiles('docker-compose*.yml') != ''
    steps:
      - uses: actions/checkout@v4

      - name: Trivy IaC 掃描
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: 'config'
          scan-ref: '.'
          format: 'table'
          exit-code: '1'
          severity: 'HIGH,CRITICAL'

團隊落地建議:不要想一次做完,分階段來

建議一:先做風險最高的三件事

如果你的團隊是第一次接觸系統強化,不要被 CIS Benchmark 幾百頁的文件嚇到。先做這三件事,就能大幅降低風險:

  1. 更新系統和套件:把已知的漏洞修掉
  2. 關閉不必要的 Port 和服務:減少攻擊面
  3. 不用 root 執行應用程式:限制入侵後的影響範圍

建議二:建立「黃金映像(Golden Image)」

不要每次部署都從零開始強化。把已經強化好的系統設定打包成映像,之後的新伺服器都從這個映像開始:

強化好的 Ubuntu + Node.js + 安全設定
  │
  ├─ 打包成 AMI(AWS)或 Docker Base Image
  │
  ├─ 所有新環境都從這個映像起步
  │
  └─ 定期更新映像(每月一次)

建議三:定期複檢,不是做一次就好

系統強化不是一次性的工作。新的漏洞會被發現、CIS Benchmark 會更新、你的系統設定可能在維護過程中被改動。建議每季做一次全面的安全設定複檢。

## 定期複檢排程建議

| 頻率 | 檢查項目 | 工具 |
|------|---------|------|
| 每天 | 容器映像漏洞掃描 | Trivy(CI/CD 自動化) |
| 每週 | 系統安全更新 | unattended-upgrades |
| 每月 | 完整安全審計 | Lynis / Docker Bench |
| 每季 | CIS Benchmark 合規檢查 | CIS-CAT / Prowler |
| 每年 | 全面安全架構審查 | 搭配滲透測試結果 |

常見問題 FAQ

Q1:系統強化跟滲透測試有什麼差別?

系統強化是「主動防禦」——你照著最佳實踐把設定調好,把門窗關上。滲透測試是「被動驗證」——你請人假裝小偷來攻擊,看還有哪裡沒關好。兩者是互補的:先做強化(關門窗),再做滲透測試(請人來試開門),最後根據測試結果再強化。

Q2:我們用的是 PaaS(如 Heroku、Render),還需要做系統強化嗎?

PaaS 幫你處理了作業系統和基礎設施的強化,但應用程式層級的強化還是你的責任。HTTP 安全標頭、速率限制、錯誤處理、環境變數管理——這些不管你部署在哪裡都要做。另外,PaaS 的設定本身也有安全選項需要檢查,例如:是否強制 HTTPS、是否限制 IP 白名單、日誌是否保留足夠天數。

Q3:CIS Benchmark 要做到 100% 合規嗎?

不需要,也不建議追求 100%。CIS Benchmark 是建議,不是法律。有些建議可能跟你的業務需求衝突(例如你的系統就是需要允許某些 Port)。重點是:你清楚知道每一條你沒有遵守的建議,並且有合理的理由。把這些「例外」記錄下來,作為安全決策的依據。

Q4:團隊沒有專職的 DevOps 或 SRE,誰來做系統強化?

在小團隊裡,系統強化可以是後端工程師的責任之一。用自動化工具(如 Lynis、Docker Bench)可以大幅降低門檻。建議指定一位 Security Champion 負責定期執行掃描、追蹤修復進度。如果預算允許,也可以考慮導入 CIS Hardened Images(已經預先強化好的雲端映像),直接省下大部分手動設定的工作。


結語:安全的最後一哩路,往往藏在設定裡

回到蓋房子的比喻。你花了幾年設計、施工、裝修,終於蓋好了一棟漂亮的房子。但如果交屋那天你不去檢查每一扇窗戶是否能鎖好、每一道門的門鎖是否正常、火災警報器的電池有沒有裝——那所有的努力都可能功虧一簣。

系統強化就是那個「交屋驗收」的步驟。它不華麗、不刺激、不像寫程式那麼有成就感,但它是安全的最後一哩路

在 SSDLC 的旅程中,我們從安全需求出發,經過安全設計、安全編碼、安全測試,現在來到了部署與維運。你的程式碼已經足夠安全了,現在讓它跑在同樣安全的環境上吧。

下一篇,我們會繼續深入部署與維運階段的另一個重要主題——安全監控與告警:建立你的資安儀表板。系統上線後,怎麼知道有沒有人在敲你的門窗?怎麼第一時間發現異常?我們下次見。


延伸閱讀

[安全測試] 004 滲透測試入門完整指南:從攻擊者角度看系統|PTES 流程與資安廠商合作實戰

「你不需要成為駭客,才能理解駭客怎麼思考。但你必須理解駭客怎麼思考,才能真正保護你的系統。」


一、滲透測試是什麼?為什麼開發者也需要懂?

想像你蓋好了一棟房子。你裝了門鎖、窗戶有柵欄、後院有圍牆。你覺得很安全,但你怎麼知道這些防護「真的有效」?

最直接的方法——請一個專業的「小偷」來試試看。

他會繞著你的房子走一圈,檢查哪扇窗戶沒鎖、哪面牆可以翻過去、門鎖能不能被撬開。然後他把發現的所有「入侵路徑」記錄下來,告訴你該怎麼修。

這就是滲透測試(Penetration Testing,簡稱 Pentest)

在軟體世界裡,滲透測試就是請資安專家用攻擊者的手法,在你授權的範圍內,嘗試入侵你的系統,找出真實存在的漏洞。它不是跑個掃描工具就結束的事,而是一場有策略、有方法論的「模擬攻擊」。

在 SSDLC 的七大階段中,滲透測試屬於階段四:安全驗證(Verification)。前面幾個階段,我們學了怎麼定義安全需求(Abuse Case)、怎麼設計安全架構(STRIDE 威脅建模)、怎麼寫安全的程式碼(輸入驗證、OWASP Top 10 防禦)。現在,是時候來驗證——你之前做的那些防護,真的擋得住攻擊嗎?

飛飛觀點:
很多團隊把滲透測試當成「交差用的報告」——上線前找廠商掃一掃、出份報告給老闆看就好。但滲透測試的真正價值不在那份報告,而在於讓你用攻擊者的眼光重新認識自己的系統。當你看到測試報告上寫著「透過修改 URL 參數就能看到別人的訂單」,那個衝擊感,比讀十本資安書都有效。


二、滲透測試 vs. 其他安全測試:搞清楚差別

在 SSDLC 的安全驗證階段,有好幾種測試方式。很多新手會搞混,以為「跑了 OWASP ZAP 就等於做了滲透測試」。讓我們用蓋房子的比喻來釐清:

測試方式 蓋房子的比喻 做什麼 誰來做 何時做
SAST(靜態分析) 檢查建材有沒有瑕疵(X 光檢查) 不執行程式,直接掃描原始碼找漏洞 工具自動化 每次 Commit
DAST(動態分析) 從外面推推門窗,看有沒有鬆的 對運行中的系統發送請求,找常見漏洞 工具自動化 每次部署
滲透測試 請專業小偷真的來闖看看 由人類專家模擬真實攻擊,串連多個弱點 資安專家(人工) 上線前 / 定期
紅隊演練 不只測房子,連保全反應速度都測 模擬完整的 APT 攻擊,包含社交工程 專業紅隊 年度

關鍵差異在於:SAST 和 DAST 是工具驅動的自動化掃描,能快速找出已知模式的漏洞;而滲透測試是人腦驅動的創造性攻擊,能發現自動化工具找不到的邏輯漏洞、串連多個低風險弱點造成高風險攻擊。

舉個例子:自動化掃描可能發現「這個 API 沒有做速率限制」,但只有滲透測試者才會想到「先用忘記密碼功能確認帳號存在,再對確認存在的帳號進行暴力破解,最後用破解的帳號去修改付款資訊」——這種攻擊鏈(Attack Chain)是工具看不到的。


三、滲透測試的類型:黑箱、白箱、灰箱

根據測試者在開始前擁有多少系統資訊,滲透測試分成三種類型:

類型 測試者知道什麼 蓋房子比喻 優點 缺點
黑箱測試 什麼都不知道,跟真正的外部攻擊者一樣 小偷只能在街上看你家外觀 最接近真實攻擊場景 耗時最長、可能遺漏內部漏洞
白箱測試 擁有完整資訊:原始碼、架構圖、帳號 小偷拿到了你家的建築藍圖和鑰匙副本 最全面、覆蓋率最高 成本較高、不完全模擬真實攻擊
灰箱測試 擁有部分資訊:如一般使用者帳號、API 文件 小偷是你家的訪客,知道一些內部格局 效率與真實度的平衡 最常見的實務選擇

飛飛觀點:
對大多數台灣中小型團隊來說,灰箱測試是最務實的選擇。給測試者一組一般使用者帳號和 API 文件,既能節省「摸索系統」的時間,又能測試出從已登入使用者角度發動的攻擊(如越權存取、權限提升)。純黑箱聽起來很酷,但可能花了一大半時間在做資訊收集,實際打到的面反而不夠廣。


四、滲透測試的標準流程:PTES 七階段方法論

滲透測試不是「隨便亂打」。業界有成熟的方法論來確保測試的系統化和可重複性。最廣泛使用的是 PTES(Penetration Testing Execution Standard),它把滲透測試分成七個階段:

階段一:前期互動(Pre-engagement Interactions)

在動手之前,先把規則說清楚。

這個階段就像你請人來測防盜系統之前,先簽好合約:可以測哪些範圍、不能碰什麼、時間多長、發現重大漏洞要怎麼通報。

關鍵產出:

## 滲透測試授權書 / 範圍定義(範例)

### 測試範圍(Scope)
- ✅ 可測試:api.example.com、www.example.com
- ✅ 可測試:iOS App v3.2、Android App v3.2
- ❌ 不可測試:第三方支付閘道(需另行授權)
- ❌ 不可測試:正式資料庫(使用測試環境)

### 測試類型
- 灰箱測試(提供測試帳號與 API 文件)

### 時程
- 測試期間:2026/03/01 ~ 2026/03/14
- 報告交付:2026/03/21

### 緊急通報機制
- 發現 CVSS ≥ 9.0 的漏洞:4 小時內通知窗口
- 發現資料洩露:立即電話通知 + Email

### 限制條件
- 不可進行 DoS 攻擊
- 不可對真實用戶資料進行操作
- 不可進行社交工程攻擊(除非另行授權)

為什麼這個階段很重要?

台灣曾有案例,測試團隊在未明確約定範圍的情況下,意外打到了合作廠商的系統,引發法律糾紛。所以——沒有授權書,不開始測試。


階段二:情報收集(Intelligence Gathering)

在發動攻擊之前,先了解你的目標。

這個階段的目的是盡可能多地蒐集目標系統的公開資訊,就像小偷踩點一樣——觀察房子周圍的環境、作息規律、有沒有監視器。

常見的情報收集方式:

# 被動收集(不直接接觸目標)

# 1. DNS 查詢 — 找出子網域
dig example.com ANY
# 或使用子網域枚舉工具
# subfinder、amass 等

# 2. 查看 HTTP Response Headers — 識別技術棧
curl -I https://www.example.com

# 可能看到:
# X-Powered-By: Express  ← Node.js!
# Server: nginx/1.18.0
# Set-Cookie: connect.sid  ← Express Session

# 3. 查看 JavaScript 檔案 — 找 API Endpoint
# 在瀏覽器開發者工具的 Sources 頁面
# 搜尋 "/api/" 可以找到前端呼叫的 API 路徑

# 4. Google Dork — 找敏感檔案
# site:example.com filetype:pdf
# site:example.com inurl:admin
# site:example.com "password" filetype:log

主動收集(直接與目標互動):

# 1. Port Scanning — 找出開放的服務
nmap -sV -sC example.com

# 2. 目錄掃描 — 找隱藏的路徑
# dirsearch、gobuster 等工具
# 可能發現 /admin、/.env、/backup 等

# 3. 技術指紋識別
# 安裝 Wappalyzer 瀏覽器擴充套件
# 自動識別網站使用的框架、CMS、伺服器

飛飛觀點:
情報收集是滲透測試中最被低估的階段。很多新手急著「開打」,跳過這一步。但資深測試者會告訴你——你花在收集情報的時間越多,後面攻擊的效率就越高。就像飛飛在 JSDC 2025 演講中提到的,光是看到 <code>X-Powered-By: Express</code>,就能判斷目標是 Node.js,接下來可以針對 Prototype Pollution、SSTI、不安全的反序列化等 Node.js 特有漏洞進行測試。


階段三:威脅建模(Threat Modeling)

根據收集到的情報,規劃攻擊策略。

這個階段就是我們在 SSDLC 階段二學過的 STRIDE 威脅建模,但方向相反——之前是防守方畫 DFD 找弱點,現在是攻擊方根據情報決定「先打哪裡」。

攻擊優先順序的判斷依據:

考量因素 說明
攻擊面大小 哪個 API Endpoint 暴露最多功能?
預期影響 打到這個點能造成多大損害?(如:取得管理員權限 > 取得一般用戶資料)
預期難度 這個漏洞利用起來容易嗎?
技術棧特性 Node.js 有 Prototype Pollution、PHP 有反序列化、Java 有 Log4Shell

階段四:漏洞分析(Vulnerability Analysis)

開始尋找具體的漏洞。

結合自動化工具和手動測試,對目標系統進行全面的弱點掃描與分析。

自動化掃描工具:

工具 類型 說明 費用
OWASP ZAP DAST 開源免費,功能強大,OWASP 官方出品 免費
Burp Suite DAST 業界標準的 Web 滲透測試工具 社群版免費 / 專業版付費
Nmap 網路掃描 Port Scanning、服務識別 免費
Nuclei 漏洞掃描 基於模板的快速漏洞掃描 免費
sqlmap 自動化 SQLi 自動偵測和利用 SQL Injection 免費

手動測試重點(依 OWASP Testing Guide):

□ 認證測試:暴力破解、預設帳密、Session 管理
□ 授權測試:越權存取(IDOR)、權限提升
□ 輸入驗證:SQL Injection、XSS、SSTI、Command Injection
□ 商業邏輯:金額竄改、流程繞過、重複操作
□ 錯誤處理:錯誤訊息洩露、Stack Trace 外洩
□ 加密傳輸:TLS 版本、憑證驗證、敏感資料明文傳輸
□ API 安全:Mass Assignment、Rate Limiting、認證繞過

階段五:漏洞利用(Exploitation)

真的動手「打」進去。

這是最刺激的階段——驗證前面發現的漏洞是否真的可以被利用,以及能造成多大的影響。

重要原則:滲透測試不是破壞測試。

目的是證明漏洞存在並評估影響,不是把系統搞壞。就像驗證門鎖可以被撬開,只要打開門就好,不需要把門拆下來。

常見的漏洞利用場景(以 Node.js 為例):

以下範例改編自飛飛在 JSDC 2025 的演講實戰案例,所有測試均在授權環境中進行:

// 場景一:SQL Injection → 取得資料庫內容
// 漏洞程式碼(不安全的寫法)
const query = <code class="kb-btn">SELECT * FROM articles WHERE id = ${req.params.id}</code>;

// 攻擊者可以透過 UNION SELECT 取得其他表的資料
// 例如:/api/articles/0 UNION SELECT null,username||':'||password,null,null,null,null FROM users--

// 場景二:Prototype Pollution → 權限提升
// 漏洞程式碼(不安全的 merge 函數)
function merge(target, source) {
  for (let key in source) {
    if (typeof source[key] === 'object') {
      target[key] = merge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

// 攻擊者提交:{"__proto__": {"isAdmin": true}}
// 之後所有新建的物件都會繼承 isAdmin: true

// 場景三:SSRF → 存取雲端 Metadata
// 漏洞程式碼(直接請求用戶提供的 URL)
const response = await axios.get(req.query.url);

// 攻擊者可以讓伺服器去請求內部服務或雲端 Metadata
// 例如:/fetch?url=http://169.254.169.254/latest/meta-data/
// 可能取得 AWS IAM Token,進而控制雲端資源

⚠️ 重要提醒: 以上所有攻擊手法僅供學習,請務必在授權的測試環境中練習。未經授權的滲透測試是違法行為。


階段六:後滲透(Post Exploitation)

進去之後,評估能走多遠。

成功利用一個漏洞後,測試者會嘗試在系統內部進一步探索:能不能存取更多資料?能不能跳到其他系統?能不能取得更高的權限?

這個階段的目的是評估實際的商業影響。例如:透過 SSRF 取得 AWS Token 後,測試者會檢查這個 Token 有哪些權限——能不能讀 S3 Bucket?能不能啟動 EC2?能不能存取 RDS 資料庫?

## 影響評估範例

### 漏洞:SSRF → AWS Metadata 洩露
- 取得的 IAM Role:WebAppRole
- 可存取的 S3 Bucket:3 個(含用戶上傳的身分證照片)
- 可存取的 RDS:唯讀權限(含完整用戶資料表)
- 預估影響:約 50 萬筆個資可能外洩
- 商業損失:可能面臨個資法最高 NT$1,500 萬罰鍰

階段七:報告撰寫(Reporting)

把發現整理成清楚的報告。

這是整個滲透測試中對甲方最有價值的產出。好的報告不只列出漏洞,還要讓不同角色的人看得懂:

報告的三層結構:

章節 給誰看 內容
執行摘要 老闆 / 主管 整體風險等級、重大發現、建議優先處理項目(不超過 2 頁)
技術發現 開發者 / 資安團隊 每個漏洞的詳細描述、重現步驟、影響範圍、修復建議
附錄 技術人員 原始掃描報告、測試日誌、工具清單

單一漏洞的報告格式(範例):

## VULN-001:水平越權存取(IDOR)

### 風險等級:高(CVSS 7.5)

### 漏洞描述
退貨查詢 API(GET /api/returns/{id})未驗證請求者與退貨申請的所有權關係。
已登入的使用者只要修改 URL 中的退貨編號,即可查看其他使用者的退貨資料,
包含姓名、電話、地址與退貨商品明細。

### 重現步驟
1. 以使用者 A 的帳號登入,取得 JWT Token
2. 查看使用者 A 自己的退貨申請:GET /api/returns/R-5001(正常回傳)
3. 將 URL 中的 ID 改為 R-5002(使用者 B 的退貨)
4. 系統回傳使用者 B 的完整退貨資料(包含個資)

### 影響評估
- 約 12,000 筆退貨紀錄可被任意已登入用戶存取
- 洩露的資料包含姓名、電話、地址(屬個資法保護範圍)
- 可能違反個資法第 27 條安全維護義務

### 修復建議
在 API Controller 中加入所有權驗證:

​```javascript
// 修復前
app.get('/api/returns/:id', auth, async (req, res) => {
  const returnRequest = await Return.findById(req.params.id);
  res.json(returnRequest);
});

// 修復後
app.get('/api/returns/:id', auth, async (req, res) => {
  const returnRequest = await Return.findById(req.params.id);
  if (returnRequest.userId !== req.user.id) {
    return res.status(403).json({ error: 'Access denied' });
  }
  res.json(returnRequest);
});
​```

### 對應標準
- OWASP Top 10 2025:A01 Broken Access Control
- CWE-639:Authorization Bypass Through User-Controlled Key

五、滲透測試的發現都在找什麼?台灣常見漏洞 Top 5

根據台灣資安廠商的公開報告和飛飛的實務觀察,以下是台灣 Web 應用最常被發現的五類漏洞:

排名 漏洞類型 佔比 常見場景 難修指數
1 存取控制缺陷(Broken Access Control) ~30% IDOR、越權操作、缺少角色驗證 ⭐⭐
2 注入攻擊(Injection) ~20% SQL Injection、XSS、Command Injection ⭐⭐
3 安全設定錯誤(Security Misconfiguration) ~18% 預設帳密、多餘的 HTTP Header、Debug 模式未關
4 敏感資料洩露(Sensitive Data Exposure) ~15% API 回傳密碼欄位、錯誤訊息洩漏技術細節 ⭐⭐
5 認證機制缺陷(Authentication Failures) ~12% 缺少帳號鎖定、JWT 未驗證簽章、Session 未過期 ⭐⭐⭐

飛飛觀點:
你會發現,這些漏洞大多不是什麼高深的 0-day 攻擊,而是最基本的安全實踐沒做好。排名第一的「存取控制缺陷」,說穿了就是沒有在後端檢查「這個人有沒有權限做這件事」。這也是為什麼我們在前面的文章中反覆強調——安全需求要從設計階段就定義清楚,不能等到滲透測試才發現。


六、如何與資安廠商合作:從選商到驗收的完整指南

對大多數台灣團隊來說,滲透測試會委託外部資安廠商執行。以下是合作的完整流程:

6.1 選擇廠商:不是越便宜越好

評估面向 該問的問題 紅色警訊
資質認證 測試人員有 OSCP、CEH、GPEN 等證照嗎? 只有公司品牌,說不出測試人員資歷
測試方法 用什麼方法論?(PTES、OWASP Testing Guide) 只說「我們有專業工具」,無法說明方法論
報告品質 能提供報告範本嗎? 報告只列工具掃描結果,沒有手動測試發現
溝通能力 發現重大漏洞時如何通報? 測試結束才一次性告知所有發現
保密協議 有完善的 NDA 嗎?測試資料如何處理? 含糊帶過資料處理方式
報價透明 報價包含幾人天?幾次複測? 只給一個總價,不說明工時分配

台灣市場的價格參考(2025-2026 年):

測試規模 大致費用 說明
小型網站(5-10 個 API) NT$15 萬 ~ NT$30 萬 3-5 人天
中型系統(20-50 個 API + App) NT$30 萬 ~ NT$80 萬 7-15 人天
大型平台(完整電商 / 金融系統) NT$80 萬 ~ NT$200 萬+ 15-30+ 人天

注意: 以上僅為市場概估,實際費用因廠商、測試深度與範圍而異。

6.2 合作前的準備工作

作為甲方(委託者),你在測試開始前需要準備:

## 甲方準備清單

### 環境準備
- [ ] 準備獨立的測試環境(不要用正式環境!)
- [ ] 測試環境的資料要脫敏(移除或遮蔽真實個資)
- [ ] 確認測試環境與正式環境的架構一致
- [ ] 準備測試帳號(各角色至少各一組)

### 文件準備
- [ ] 系統架構圖(簡易版即可)
- [ ] API 文件(Swagger / Postman Collection)
- [ ] 角色與權限矩陣
- [ ] 上次測試的報告(如有)

### 內部溝通
- [ ] 通知 IT 團隊測試時間,避免被當成真實攻擊
- [ ] 指定窗口人員,負責與廠商溝通
- [ ] 確認緊急通報流程與聯絡方式
- [ ] 確認測試期間的變更凍結(避免測試中改版)

6.3 測試期間的互動

好的合作不是「丟出去就不管」。測試期間建議:

每日站會(15 分鐘):

  • 測試者分享今天測試的範圍和初步發現
  • 甲方回答測試者對系統邏輯的疑問
  • 及時討論是否需要調整測試範圍

緊急通報:

  • 約定 CVSS ≥ 9.0 的漏洞要在 4 小時內通知
  • 發現資料洩露跡象要立即通報

6.4 報告驗收與複測

收到報告後,不是簽收就結束。正確的流程是:

收到報告 → 內部 Review → 確認修復優先序 → 修復漏洞 → 安排複測 → 確認修復完成

修復優先順序建議:

CVSS 分數 風險等級 建議修復時程
9.0 ~ 10.0 嚴重 24 ~ 72 小時內修復
7.0 ~ 8.9 1 ~ 2 週內修復
4.0 ~ 6.9 下個 Sprint 修復
0.1 ~ 3.9 排入 Backlog,定期處理

複測(Retest)的重要性:

很多團隊修了漏洞後就覺得沒事了,但修復不當的情況很常見。例如:修了 SQL Injection 但只做了前端驗證,後端沒改;修了 XSS 但只擋了 <code><script></code>,沒擋 <code><img onerror=…></code>。

複測就是讓廠商回來確認——你修的方法真的有效嗎?

飛飛觀點:
在簽合約時,就要確認報價包含幾次複測。一般至少要有一次免費複測。如果廠商說「複測另外收費」,建議把複測費用也納入總預算中,因為不做複測的滲透測試只完成了一半。


七、開發者也能做的「類滲透測試」:自我健檢

不是每次都需要花大錢請外部廠商。開發者可以用以下工具和方法,在日常開發中進行基本的安全檢測:

7.1 免費工具推薦

工具 用途 難度 推薦指數
OWASP ZAP 自動化 Web 掃描 ⭐⭐ 必裝
Burp Suite Community 手動 Web 滲透測試 ⭐⭐⭐ 必裝
nmap 網路掃描與服務識別 ⭐⭐ 推薦
sqlmap SQL Injection 自動化 ⭐⭐ 針對性使用
Nuclei 基於模板的漏洞掃描 ⭐⭐ 推薦
npm audit Node.js 套件漏洞檢查 必用

7.2 快速安全自檢 Checklist

每次部署前,花 30 分鐘走一遍:

## 部署前安全自檢

### 認證與授權
- [ ] 所有 API Endpoint 都有認證機制?
- [ ] 敏感操作有做權限驗證?(不只檢查是否登入,還檢查角色)
- [ ] 不同使用者無法互相存取資料?(改 ID 試試看)

### 輸入與輸出
- [ ] 所有使用者輸入都有在後端做驗證?
- [ ] API 回應中沒有多餘的欄位?(如 password、token)
- [ ] 錯誤訊息沒有洩漏系統資訊?(如 SQL 錯誤、Stack Trace)

### 設定安全
- [ ] Debug 模式已關閉?
- [ ] X-Powered-By Header 已移除?
- [ ] CORS 設定是否過於寬鬆?(不要用 <code>*</code>)
- [ ] HTTPS 是否強制啟用?

### 依賴套件
- [ ] npm audit / snyk test 沒有高風險漏洞?
- [ ] 沒有使用已棄用的套件?

### 機敏資料
- [ ] 密碼有用 bcrypt / Argon2 雜湊?
- [ ] API Key、Secret 沒有寫死在程式碼中?
- [ ] 日誌中沒有記錄密碼或 Token?

7.3 推薦練習平台

想提升滲透測試能力,最好的方式就是實際練習:

平台 特色 適合對象 費用
OWASP Juice Shop 故意有漏洞的購物網站,關卡式 初學者 免費
OWASP WebGoat 互動式教學,邊學邊打 初學者 免費
PortSwigger Web Security Academy 系統化的 Web 安全課程 + Lab 初中級 免費
HackTheBox 各種難度的靶機 中高級 基礎免費 / 進階付費
nodelab.feifei.tw Node.js 專屬靶站,飛飛開發 初中級(Node.js 開發者) 免費

八、將滲透測試融入 SSDLC:不只是上線前的最後一關

很多團隊把滲透測試當成「上線前的最後一關」,但在成熟的 SSDLC 流程中,滲透測試的思維應該貫穿整個開發週期:

SSDLC 階段 滲透測試思維的應用
安全需求 寫 Abuse Case 時,就是用攻擊者角度思考
安全設計 做 STRIDE 威脅建模時,就是在模擬攻擊路徑
安全開發 寫程式時主動測試自己的輸入驗證是否可被繞過
安全測試 正式的滲透測試 + SAST/DAST 自動化掃描
安全部署 安全設定檢查、環境強化、部署前驗證
安全維運 持續監控、定期複測、漏洞管理
安全教育 將測試發現轉化為團隊知識,避免同類漏洞重複出現

建議的測試頻率:

測試類型 頻率 適用場景
SAST 靜態掃描 每次 Commit / PR CI/CD 自動化
DAST 動態掃描 每次部署到測試環境 CI/CD 自動化
開發者自檢 每個 Sprint 使用 Checklist
外部滲透測試 每年 1-2 次 / 重大改版前 委託資安廠商
紅隊演練 每年 1 次 成熟度較高的組織

九、常見問題 FAQ

Q1:我們的系統很小,也需要做滲透測試嗎?

系統大小跟是否需要滲透測試沒有絕對關係。關鍵是你的系統處理什麼資料。如果你的系統有處理個資(姓名、Email、電話)、金流、或是使用者的敏感資料,不論規模大小,都建議至少做過一次基本的安全測試。

小系統可以先從「開發者自檢 + OWASP ZAP 自動掃描」開始,不一定要花大錢請外部廠商。

Q2:滲透測試會不會把我的系統打壞?

專業的滲透測試不會以破壞系統為目的。在前期互動階段,會明確約定不做 DoS 攻擊、不刪除資料、不修改正式環境的設定。這也是為什麼一定要準備獨立的測試環境——即使測試過程出了意外,也不會影響正式服務。

Q3:收到報告後發現一堆漏洞,是不是代表我們團隊很差?

完全不是。滲透測試找到漏洞是正常的預期的結果。就像每年做健康檢查,多少會發現一些指標需要注意。找到漏洞代表你做了正確的事——在攻擊者之前發現問題。真正該擔心的是「做了測試卻什麼都沒發現」,那可能代表測試不夠深入。

Q4:我們請廠商測了一次,以後還需要再測嗎?

需要。系統在持續迭代,每次新增功能都可能引入新的漏洞。建議至少每年做一次完整的滲透測試,以及在每次重大改版前做一次。同時,在日常開發中持續使用 SAST/DAST 工具進行自動化安全掃描。


十、結語:攻擊是最好的防禦教材

在 SSDLC 的旅程中,滲透測試是一個特別的存在——它讓你暫時放下防守者的身分,戴上攻擊者的面具,用另一個視角審視自己精心打造的系統。

這個視角轉換的價值,遠超過一份報告上列出的漏洞數量。

當你看到自己寫的 API 被繞過權限檢查,你會深刻理解為什麼「永遠不要信任前端傳來的資料」。當你看到情報收集階段就能從 HTTP Header 判斷出你的技術棧,你會真正意識到為什麼要隱藏 <code>X-Powered-By</code>。當你看到一個低風險的資訊洩露被串連成一條完整的攻擊鏈,你會明白為什麼每一個小漏洞都值得修

滲透測試不只是驗證工作,更是最好的學習機會。

從今天開始,你可以做的第一步很簡單——打開你的系統,試著改一改 URL 裡的 ID,看看能不能看到別人的資料。如果可以——恭喜你,你剛剛完成了你的第一次「滲透測試」。

「安全不是恐懼,而是創造的基礎。」 而滲透測試,就是讓你確認這個基礎夠不夠穩固的最佳方式。


延伸閱讀

[安全測試] 003 CI/CD 安全整合:在 Pipeline 加入安全關卡——讓每次部署都通過資安體檢

「房子蓋好後,不是直接交屋就好,你得先請結構技師檢查、消防設備測試、水電驗收。
CI/CD Pipeline 也一樣——每一次部署,都應該先通過一輪資安體檢。
自動化不是為了取代人,而是讓安全不再被遺忘。」
— SSDLC by 飛飛


一、CI/CD 安全整合是什麼?為什麼你的 Pipeline 需要安全關卡?

在 SSDLC 蓋房子的旅程中,我們已經走過了安全需求定義(確認要防震防火防盜)、安全設計(畫好建築藍圖)、安全實作(使用合格建材施工)。現在,我們來到了階段四:安全驗證(Verification)——房子蓋好後,交屋前的「安全檢查」。

想像你蓋了一棟漂亮的房子,建材用的是防火等級的,電線也符合規範。但如果交屋那天,沒有人來驗收——沒人檢查消防灑水器能不能動、沒人測試逃生門能不能開、沒人看排水管有沒有接好——你敢住進去嗎?

CI/CD 安全整合,就是在你的軟體「交屋」(部署上線)之前,加入一道自動化的安全驗收流程。

傳統的做法是:開發者寫完程式碼、推上 Git、CI 跑一跑單元測試、通過了就部署。安全?等上線之後再找資安公司來滲透測試吧。

問題是,等到滲透測試報告出來,漏洞已經在線上跑了好幾週。就像房子已經住了三個月,才發現消防系統根本沒裝——代價極高。

CI/CD 安全整合的核心思想是:把安全檢查「左移」到部署流程中,讓每一次 git push 都自動觸發安全掃描。 發現問題就擋住、修好再上線,不讓漏洞有機會溜進生產環境。

飛飛觀點:
很多人覺得「加安全掃描會拖慢部署速度」。但想想看,你會因為安全帶「太麻煩」就不繫嗎?CI/CD 安全掃描通常只多花 3-8 分鐘,但它能在漏洞到達使用者之前就攔截下來。花 5 分鐘預防,好過花 5 天善後。


二、傳統做法 vs. DevSecOps:安全不該是最後一關

先來看看傳統做法和 DevSecOps 做法的差別:

面向 傳統做法 DevSecOps 做法
安全檢查時機 上線前或上線後,做一次性滲透測試 每次 push/PR 都自動掃描
發現漏洞的時機 開發結束後,可能已經過了好幾週 寫完程式碼的幾分鐘內就知道
修復成本 高(要回頭改已經整合的功能) 低(問題還在開發者腦中,馬上修)
責任歸屬 「資安團隊的事」 「每個開發者的事」
自動化程度 低,多靠人工審查 高,機器自動掃描、自動擋
覆蓋率 低,只檢查一次 高,每次變更都檢查

用蓋房子的比喻來說:

傳統做法就像房子蓋完才請消防隊來檢查——萬一不合格,整面牆要拆掉重來。

DevSecOps 就像每砌一面牆,工地監工就立刻檢查有沒有用對建材、有沒有預留管線——問題當下就能修正,不用返工。


三、安全 Pipeline 的四道關卡:你的 CI/CD 防火牆

一條完整的安全 Pipeline,就像房屋驗收時的四個檢查站:

┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│  🔍 SAST    │───▶│  📦 SCA     │───▶│  🔐 Secret  │───▶│  🌐 DAST    │
│  靜態分析   │    │  依賴掃描   │    │  機密掃描   │    │  動態測試   │
│             │    │             │    │             │    │             │
│ 檢查程式碼  │    │ 檢查第三方  │    │ 檢查有沒有  │    │ 檢查運行中  │
│ 有沒有漏洞  │    │ 套件有沒有  │    │ 把密碼推上  │    │ 的網站有沒  │
│             │    │ 已知漏洞    │    │ Git         │    │ 有漏洞      │
└─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘
    結構技師           建材檢驗員         保全系統檢查         消防測試

關卡一:SAST(靜態應用程式安全測試)

比喻: 結構技師看藍圖和施工品質,不用等房子蓋好就能發現問題。

SAST 直接掃描你的原始碼,在程式碼還沒執行前就找出潛在漏洞:SQL Injection、XSS、Path Traversal、硬編碼密碼等。

推薦工具:

工具 特色 費用
Semgrep 輕量快速、規則豐富、支援多語言 免費(開源)
SonarQube 全面的程式碼品質 + 安全掃描 Community 版免費
CodeQL GitHub 原生整合,語意分析強大 GitHub 公開 Repo 免費
ESLint Security Node.js 專用,整合 IDE 即時提示 免費(開源)

關卡二:SCA(軟體組成分析)

比喻: 建材檢驗員確認你用的水泥、鋼筋、磁磚是不是合格品。

SCA 掃描你專案的所有第三方依賴套件,檢查有沒有已知的 CVE 漏洞。還記得 2021 年的 Log4Shell 嗎?一個 Log4j 的漏洞影響了全世界數百萬系統——如果你的 Pipeline 有 SCA 掃描,就能在第一時間收到警報。

推薦工具:

工具 特色 費用
npm audit Node.js 內建 免費
Snyk 自動修復建議、整合豐富 免費方案可用
OWASP Dependency-Check 開源、支援多語言 免費(開源)
Trivy 容器 + 依賴 + IaC 全方位掃描 免費(開源)

關卡三:Secret Scanning(機密掃描)

比喻: 保全公司檢查你有沒有把鑰匙插在門上忘了拔。

Secret Scanning 掃描你的程式碼和 Git 歷史紀錄,找出不小心被推上去的 API Key、資料庫密碼、JWT Secret 等機密資訊。根據 GitGuardian 的統計,平均每 1,000 次 commit 就有 5.5 個機密被意外推上公開 Repo。

推薦工具:

工具 特色 費用
GitHub Secret Scanning GitHub 原生、支援多種 Token 格式 公開 Repo 免費
GitLeaks 快速、可自訂規則 免費(開源)
TruffleHog 深度掃描 Git 歷史 免費(開源)

關卡四:DAST(動態應用程式安全測試)

比喻: 消防測試——實際點火看灑水器會不會動。

DAST 對執行中的應用程式發送惡意請求,模擬真實攻擊。它不看原始碼,而是看系統的實際行為——就像真正的攻擊者會做的事情。

推薦工具:

工具 特色 費用
OWASP ZAP 開源標準、功能強大 免費(開源)
Nuclei 模板化掃描、社群維護規則 免費(開源)
Burp Suite 業界標準,功能最全面 社群版免費

飛飛觀點:
不需要一次全部導入。如果你的團隊剛開始做 CI/CD 安全整合,先從 SCA(npm audit)和 Secret Scanning(GitLeaks)開始——這兩個最容易導入、誤報率最低、投入產出比最高。穩定之後再加入 SAST 和 DAST。


四、GitHub Actions 安全掃描實戰設定

接下來,我們用 GitHub Actions 來實作一條完整的安全 Pipeline。以台灣常見的 Node.js 電商專案為例,一步步加入四道安全關卡。

4.1 基礎架構:安全掃描 Workflow

先建立 <code>.github/workflows/security.yml</code>:

# .github/workflows/security.yml
name: 🔒 Security Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]
  schedule:
    # 每週一早上 9 點(UTC+8)自動掃描,確保依賴漏洞被及時發現
    - cron: '0 1 * * 1'

permissions:
  contents: read
  security-events: write  # 讓掃描結果可以顯示在 GitHub Security Tab

jobs:
  # ═══════════════════════════════════════
  # 關卡一:依賴套件漏洞掃描(SCA)
  # ═══════════════════════════════════════
  dependency-check:
    name: 📦 依賴套件安全檢查
    runs-on: ubuntu-latest
    steps:
      - name: Checkout 程式碼
        uses: actions/checkout@v4

      - name: 安裝 Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: 安裝依賴
        run: npm ci

      # npm audit:Node.js 內建的依賴漏洞掃描
      - name: npm audit — 掃描已知漏洞
        run: |
          echo "🔍 正在掃描 npm 依賴套件漏洞..."
          npm audit --audit-level=high --omit=dev 2>&1 | tee audit-result.txt
          # 只阻擋 high 和 critical 等級的漏洞
          # --omit=dev 忽略開發依賴,減少誤報

      # (進階)使用 Snyk 做更深度的 SCA 掃描
      # - name: Snyk 依賴掃描
      #   uses: snyk/actions/node@master
      #   env:
      #     SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
      #   with:
      #     args: --severity-threshold=high

  # ═══════════════════════════════════════
  # 關卡二:機密掃描(Secret Scanning)
  # ═══════════════════════════════════════
  secret-scan:
    name: 🔐 機密資訊掃描
    runs-on: ubuntu-latest
    steps:
      - name: Checkout 程式碼(含完整 Git 歷史)
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # 掃描完整 Git 歷史

      - name: GitLeaks — 掃描洩漏的機密
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # GitLeaks 會掃描:API Key、資料庫密碼、JWT Secret、
          # AWS Access Key、Private Key 等常見機密格式

  # ═══════════════════════════════════════
  # 關卡三:靜態程式碼安全分析(SAST)
  # ═══════════════════════════════════════
  sast-scan:
    name: 🔍 靜態安全分析
    runs-on: ubuntu-latest
    steps:
      - name: Checkout 程式碼
        uses: actions/checkout@v4

      # Semgrep:輕量級 SAST 工具,支援 OWASP Top 10 規則
      - name: Semgrep — 掃描程式碼安全漏洞
        uses: returntocorp/semgrep-action@v1
        with:
          config: >-
            p/owasp-top-ten
            p/nodejs
            p/javascript
            p/typescript
          # p/owasp-top-ten:涵蓋 OWASP Top 10 的掃描規則
          # p/nodejs:Node.js 特定的安全規則
          # p/javascript:JavaScript 通用安全規則

  # ═══════════════════════════════════════
  # 關卡四:容器映像掃描(如果使用 Docker)
  # ═══════════════════════════════════════
  container-scan:
    name: 🐳 容器安全掃描
    runs-on: ubuntu-latest
    if: hashFiles('Dockerfile') != ''  # 只在有 Dockerfile 時執行
    steps:
      - name: Checkout 程式碼
        uses: actions/checkout@v4

      - name: 建置 Docker 映像
        run: docker build -t my-app:scan .

      # Trivy:掃描容器映像中的 OS 套件和應用程式依賴漏洞
      - name: Trivy — 掃描容器漏洞
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'my-app:scan'
          format: 'table'
          exit-code: '1'            # 發現漏洞就失敗
          severity: 'HIGH,CRITICAL' # 只阻擋高風險和嚴重漏洞
          ignore-unfixed: true      # 忽略尚無修補的漏洞

4.2 進階:ESLint 安全規則整合

除了 CI 的掃描,在開發階段就能用 ESLint 安全外掛即時提醒開發者:

// .eslintrc.js — 加入安全規則
module.exports = {
  plugins: ['security'],
  extends: ['plugin:security/recommended'],
  rules: {
    // 偵測不安全的正規表達式(ReDoS 攻擊)
    'security/detect-unsafe-regex': 'error',
    // 偵測 eval 相關的危險用法
    'security/detect-eval-with-expression': 'error',
    // 偵測可能的物件注入(Prototype Pollution)
    'security/detect-object-injection': 'warn',
    // 偵測不安全的 Buffer 用法
    'security/detect-buffer-noassert': 'error',
    // 偵測可能的 timing attack
    'security/detect-possible-timing-attacks': 'warn',
    // 偵測非字面值的正規表達式(可能的 ReDoS)
    'security/detect-non-literal-regexp': 'warn',
    // 偵測非字面值的 require(可能的任意檔案載入)
    'security/detect-non-literal-require': 'warn',
  },
};

把 ESLint 安全檢查也加進 CI:

  # 加在 security.yml 的 jobs 裡
  lint-security:
    name: 📝 ESLint 安全規則檢查
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: ESLint 安全檢查
        run: npx eslint . --ext .js,.ts --format stylish

4.3 完整的安全 Pipeline 流程圖

 開發者 git push
      │
      ▼
┌──────────────────┐
│   GitHub Actions  │
│   觸發 Workflow   │
└──────┬───────────┘
       │
       ├──▶ 📦 dependency-check (SCA)
       │      └── npm audit --audit-level=high
       │
       ├──▶ 🔐 secret-scan
       │      └── GitLeaks 掃描完整 Git 歷史
       │
       ├──▶ 🔍 sast-scan (SAST)
       │      └── Semgrep OWASP Top 10 規則
       │
       ├──▶ 📝 lint-security
       │      └── ESLint Security Plugin
       │
       └──▶ 🐳 container-scan(如果有 Dockerfile)
              └── Trivy 容器映像掃描
       │
       ▼
┌──────────────────┐
│  全部通過?       │
│  ✅ → 允許合併    │
│  ❌ → 阻擋部署    │
└──────────────────┘

五、設定安全品質門檻(Security Quality Gate)

有了安全掃描工具還不夠,你還需要定義明確的品質門檻——什麼情況下要阻擋部署、什麼情況下只是警告。

5.1 品質門檻的三個等級

就像房屋驗收有不同等級的缺失:

等級 說明 Pipeline 行為 範例
🔴 Critical(嚴重) 立即可被利用的漏洞 阻擋部署,必須修復 SQL Injection、RCE、硬編碼的 API Key
🟡 High(高風險) 有風險但需要特定條件才能利用 阻擋 PR 合併,限期修復 XSS、CSRF、高風險 CVE 依賴
🟢 Medium/Low(中低風險) 風險較低或難以利用 發出警告,排入 backlog 資訊洩漏、低風險 CVE、代碼品質問題

5.2 實作品質門檻:建立統一的安全檢查腳本

// scripts/security-gate.js — 統一的安全品質門檻判斷
const fs = require('fs');

/**
 * 安全品質門檻設定
 * 根據你的團隊風險承受度調整
 */
const SECURITY_GATE = {
  // SCA(依賴漏洞)
  sca: {
    block: ['critical', 'high'],      // 這些等級會阻擋部署
    warn: ['moderate'],                // 這些等級只發出警告
    ignore: ['low', 'info'],           // 這些等級靜默忽略
    maxAge: 30,                        // 已知漏洞超過 30 天未修會升級為 block
  },

  // SAST(程式碼漏洞)
  sast: {
    block: ['ERROR'],                  // Semgrep ERROR 等級阻擋
    warn: ['WARNING'],                 // WARNING 等級發出提醒
    maxFindings: 0,                    // block 等級的漏洞數量上限
  },

  // Secret(機密洩漏)
  secret: {
    block: true,                       // 只要偵測到機密就阻擋,零容忍
  },

  // Container(容器漏洞)
  container: {
    block: ['CRITICAL', 'HIGH'],
    ignoreUnfixed: true,               // 忽略尚無修補的漏洞
  },
};

/**
 * 檢查 npm audit 結果
 */
function checkSCAResults(auditOutput) {
  const vulnerabilities = JSON.parse(auditOutput);
  const blocked = [];
  const warned = [];

  for (const [name, detail] of Object.entries(vulnerabilities.vulnerabilities || {})) {
    if (SECURITY_GATE.sca.block.includes(detail.severity)) {
      blocked.push(<code class="kb-btn">🔴 [${detail.severity.toUpperCase()}] ${name}: ${detail.title || detail.url}</code>);
    } else if (SECURITY_GATE.sca.warn.includes(detail.severity)) {
      warned.push(<code class="kb-btn">🟡 [${detail.severity.toUpperCase()}] ${name}: ${detail.title || detail.url}</code>);
    }
  }

  return { blocked, warned, passed: blocked.length === 0 };
}

/**
 * 輸出報告
 */
function printReport(results) {
  console.log('\n╔══════════════════════════════════════╗');
  console.log('║    🔒 Security Quality Gate Report    ║');
  console.log('╚══════════════════════════════════════╝\n');

  if (results.blocked.length > 0) {
    console.log('❌ 以下問題必須修復才能部署:\n');
    results.blocked.forEach(item => console.log(<code class="kb-btn">  ${item}</code>));
    console.log('');
  }

  if (results.warned.length > 0) {
    console.log('⚠️  以下問題建議修復(不阻擋部署):\n');
    results.warned.forEach(item => console.log(<code class="kb-btn">  ${item}</code>));
    console.log('');
  }

  if (results.passed) {
    console.log('✅ Security Quality Gate: PASSED\n');
  } else {
    console.log('❌ Security Quality Gate: FAILED\n');
    console.log('請修復以上 🔴 標記的問題後重新提交。');
    process.exit(1);  // 非零退出碼 → CI 失敗 → 阻擋部署
  }
}

module.exports = { SECURITY_GATE, checkSCAResults, printReport };

5.3 在 GitHub Actions 中套用品質門檻

  # 在所有掃描完成後,統一評估是否通過品質門檻
  security-gate:
    name: 🚦 安全品質門檻評估
    needs: [dependency-check, secret-scan, sast-scan, lint-security]
    runs-on: ubuntu-latest
    if: always()  # 即使前面的 job 失敗也要跑,才能給出完整報告
    steps:
      - name: 檢查所有安全掃描結果
        run: |
          echo "╔══════════════════════════════════════╗"
          echo "║    🔒 Security Quality Gate Report    ║"
          echo "╚══════════════════════════════════════╝"
          echo ""

          FAILED=0

          # 檢查依賴掃描
          if [ "${{ needs.dependency-check.result }}" == "failure" ]; then
            echo "❌ 依賴套件掃描:發現高風險漏洞"
            FAILED=1
          else
            echo "✅ 依賴套件掃描:通過"
          fi

          # 檢查機密掃描
          if [ "${{ needs.secret-scan.result }}" == "failure" ]; then
            echo "❌ 機密資訊掃描:偵測到洩漏的機密!"
            FAILED=1
          else
            echo "✅ 機密資訊掃描:通過"
          fi

          # 檢查 SAST
          if [ "${{ needs.sast-scan.result }}" == "failure" ]; then
            echo "❌ 靜態安全分析:發現程式碼漏洞"
            FAILED=1
          else
            echo "✅ 靜態安全分析:通過"
          fi

          # 檢查 ESLint 安全
          if [ "${{ needs.lint-security.result }}" == "failure" ]; then
            echo "⚠️  ESLint 安全規則:有警告(不阻擋)"
          else
            echo "✅ ESLint 安全規則:通過"
          fi

          echo ""
          if [ "$FAILED" -eq 1 ]; then
            echo "❌ Security Quality Gate: FAILED"
            echo "請修復以上問題後重新提交 PR。"
            exit 1
          else
            echo "✅ Security Quality Gate: PASSED"
            echo "所有安全檢查通過,可以安全合併。"
          fi

5.4 設定 Branch Protection 強制安全檢查

光有 CI 掃描還不夠,你要確保「不通過就不能合併」。在 GitHub 設定 Branch Protection Rules:

GitHub → Repository Settings → Branches → Branch protection rules

☑️ Require a pull request before merging
☑️ Require status checks to pass before merging
    → 搜尋並勾選:
    ✅ 📦 依賴套件安全檢查
    ✅ 🔐 機密資訊掃描
    ✅ 🔍 靜態安全分析
    ✅ 🚦 安全品質門檻評估
☑️ Require branches to be up to date before merging
☑️ Do not allow bypassing the above settings

這樣就算是 admin 也不能跳過安全檢查直接合併——就像消防檢查不合格的房子,不管你是屋主還是建商,都不能強制交屋。


六、實戰案例:台灣電商平台的安全 Pipeline

讓我們用一個完整的台灣電商情境,從頭到尾走一遍安全 Pipeline 的運作方式。

情境:ShopTW 電商平台

ShopTW 是一個台灣的電商平台,使用 Node.js + Express + PostgreSQL,部署在 AWS 上。團隊有 5 位工程師,剛開始導入 SSDLC。

一個普通的工作日:

工程師小明正在開發「商品評論功能」,他寫完了 API 程式碼,準備提交 PR:

// routes/reviews.js — 小明寫的商品評論 API
const express = require('express');
const router = express.Router();
const db = require('../db');

// 新增評論
router.post('/api/products/:id/reviews', async (req, res) => {
  const productId = req.params.id;
  const { rating, comment } = req.body;
  const userId = req.user.id;

  // ⚠️ 問題 1:SQL 字串拼接
  const query = `INSERT INTO reviews (product_id, user_id, rating, comment) 
                 VALUES ('${productId}', '${userId}', ${rating}, '${comment}')`;

  await db.query(query);
  res.json({ message: '評論已送出' });
});

// 查詢評論
router.get('/api/products/:id/reviews', async (req, res) => {
  const productId = req.params.id;
  const sort = req.query.sort || 'created_at';

  // ⚠️ 問題 2:排序欄位沒有白名單驗證
  const query = <code class="kb-btn">SELECT * FROM reviews WHERE product_id = '${productId}' ORDER BY ${sort}</code>;
  const result = await db.query(query);

  // ⚠️ 問題 3:直接回傳完整資料,可能包含敏感資訊
  res.json(result.rows);
});

module.exports = router;

小明還不小心在設定檔裡留了一段:

// config/database.js
// ⚠️ 問題 4:硬編碼的資料庫密碼
module.exports = {
  host: 'shopdb.abc123.ap-northeast-1.rds.amazonaws.com',
  user: 'shop_admin',
  password: 'ShopTW2024!SuperSecret',  // TODO: 之後改用環境變數
  database: 'shoptw_production',
};

小明提交 PR 後,安全 Pipeline 自動啟動:

🔍 SAST (Semgrep) 掃描結果:
  ❌ [ERROR] sql-injection — routes/reviews.js:12
     SQL query built with string concatenation.
     Use parameterized queries instead.

  ❌ [ERROR] sql-injection — routes/reviews.js:23
     User-controlled data in SQL ORDER BY clause.
     Use an allowlist for sort fields.

📦 SCA (npm audit) 掃描結果:
  ⚠️ [HIGH] express@4.17.1 — Prototype Pollution (CVE-2024-XXXX)
     Recommendation: Upgrade to express@4.21.0

🔐 Secret Scanning (GitLeaks) 掃描結果:
  ❌ [CRITICAL] Hardcoded password detected — config/database.js:5
     Pattern: password = 'ShopTW2024!SuperSecret'

🚦 Security Quality Gate: ❌ FAILED
   修復 2 個 SQL Injection 漏洞和 1 個機密洩漏後重新提交。

Pipeline 阻擋了合併。 小明看到報告後,逐一修復:

// ✅ 修復後的 routes/reviews.js
const express = require('express');
const router = express.Router();
const db = require('../db');
const { body, param, query, validationResult } = require('express-validator');

// 排序欄位白名單
const ALLOWED_SORT_FIELDS = ['created_at', 'rating'];

// 新增評論 — 使用參數化查詢 + 輸入驗證
router.post('/api/products/:id/reviews',
  [
    param('id').isUUID(),
    body('rating').isInt({ min: 1, max: 5 }),
    body('comment').isString().isLength({ min: 1, max: 1000 }).trim().escape(),
  ],
  async (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ error: '輸入資料格式不正確' });
    }

    const { id: productId } = req.params;
    const { rating, comment } = req.body;
    const userId = req.user.id;

    // ✅ 參數化查詢,避免 SQL Injection
    const query = `INSERT INTO reviews (product_id, user_id, rating, comment) 
                   VALUES ($1, $2, $3, $4) RETURNING id`;
    const result = await db.query(query, [productId, userId, rating, comment]);

    res.status(201).json({ 
      message: '評論已送出',
      reviewId: result.rows[0].id 
    });
  }
);

// 查詢評論 — 白名單驗證排序欄位
router.get('/api/products/:id/reviews',
  [
    param('id').isUUID(),
    query('sort').optional().isIn(ALLOWED_SORT_FIELDS),
  ],
  async (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ error: '查詢參數不正確' });
    }

    const { id: productId } = req.params;
    const sort = ALLOWED_SORT_FIELDS.includes(req.query.sort) 
      ? req.query.sort 
      : 'created_at';

    // ✅ 參數化查詢 + 白名單排序
    const result = await db.query(
      `SELECT id, rating, comment, created_at 
       FROM reviews WHERE product_id = $1 ORDER BY ${sort} DESC`,
      [productId]
    );

    // ✅ 只回傳必要欄位
    res.json({ reviews: result.rows });
  }
);

module.exports = router;
// ✅ 修復後的 config/database.js — 使用環境變數
module.exports = {
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
};

小明重新推上程式碼,Pipeline 再次執行:

🔍 SAST: ✅ 通過(0 個漏洞)
📦 SCA:  ⚠️ 1 個中風險依賴(已排入下週更新計畫)
🔐 Secret: ✅ 通過(無機密洩漏)
🚦 Security Quality Gate: ✅ PASSED

PR 順利合併。整個修復過程只花了 30 分鐘。如果這些漏洞等到上線後才被滲透測試發現,可能需要好幾天的緊急修補,還有個資外洩的風險。


七、可複用的安全 Pipeline 模板

以下是一個可以直接複製到你專案的完整模板:

GitHub Actions 安全 Pipeline 模板

# .github/workflows/security.yml
# 
# SSDLC 安全 Pipeline 模板
# 適用於 Node.js 專案
# 
# 使用方式:
# 1. 將此檔案複製到 .github/workflows/ 目錄
# 2. 設定 Branch Protection Rules 要求這些 checks 必須通過
# 3. 根據團隊需求調整 severity threshold
#
name: 🔒 Security Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]
  schedule:
    - cron: '0 1 * * 1'  # 每週一 UTC 01:00(台灣時間 09:00)

permissions:
  contents: read
  security-events: write

jobs:
  sca:
    name: 📦 SCA — 依賴漏洞掃描
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm audit --audit-level=high --omit=dev

  secrets:
    name: 🔐 Secrets — 機密掃描
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  sast:
    name: 🔍 SAST — 靜態安全分析
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: returntocorp/semgrep-action@v1
        with:
          config: >-
            p/owasp-top-ten
            p/nodejs

  gate:
    name: 🚦 Security Gate
    needs: [sca, secrets, sast]
    runs-on: ubuntu-latest
    if: always()
    steps:
      - run: |
          echo "=== Security Quality Gate ==="
          PASS=true
          [ "${{ needs.sca.result }}" = "failure" ] && echo "❌ SCA" && PASS=false || echo "✅ SCA"
          [ "${{ needs.secrets.result }}" = "failure" ] && echo "❌ Secrets" && PASS=false || echo "✅ Secrets"
          [ "${{ needs.sast.result }}" = "failure" ] && echo "❌ SAST" && PASS=false || echo "✅ SAST"
          [ "$PASS" = "false" ] && exit 1
          echo "✅ All security checks passed"

安全 Pipeline 導入 Checklist

## CI/CD 安全整合導入 Checklist

### 第一週:基礎建設
- [ ] 建立 .github/workflows/security.yml
- [ ] 設定 npm audit(SCA 掃描)
- [ ] 設定 GitLeaks(機密掃描)
- [ ] 設定 Branch Protection Rules

### 第二週:強化掃描
- [ ] 加入 Semgrep(SAST 掃描)
- [ ] 設定 ESLint Security Plugin
- [ ] 定義安全品質門檻(block / warn / ignore)
- [ ] 設定每週排程掃描(cron job)

### 第三週:團隊協作
- [ ] 撰寫「安全掃描結果處理 SOP」
- [ ] 設定 Slack/Teams 通知(掃描失敗時通知頻道)
- [ ] 為團隊做一次安全 Pipeline 教學
- [ ] 建立 .gitleaksignore(合理的排除清單)

### 第四週:持續優化
- [ ] 檢視過去一個月的掃描結果,調整誤報規則
- [ ] 評估是否加入 DAST(動態掃描)
- [ ] 評估是否加入容器掃描(如果使用 Docker)
- [ ] 將安全掃描時間納入團隊的 Sprint 規劃

八、團隊落地建議:讓安全 Pipeline 不只是「裝飾品」

建議一:從「不阻擋」到「阻擋」的漸進式導入

很多團隊一開始就設定「有任何漏洞就擋住」,結果開發者天天被擋、產生抗拒心理,最後乾脆把安全 Pipeline 關掉。

漸進式導入策略:

階段 時間 策略 說明
觀察期 第 1-2 週 只掃描、不阻擋 讓團隊熟悉工具,觀察誤報率
試行期 第 3-4 週 只阻擋 Critical 只擋住最嚴重的問題(如機密洩漏、RCE)
正式期 第 5 週起 阻擋 Critical + High 開始對高風險漏洞說「不」
成熟期 第 9 週起 完整品質門檻 包含 Medium 的限期修復機制

建議二:處理誤報——別讓「狼來了」毀掉信任

安全掃描工具一定會有誤報(False Positive)。如果團隊每天看到一堆「漏洞警告」結果都是誤報,最後大家就會習慣性地忽略——就像每天喊「狼來了」的牧童。

處理誤報的方式:

# .semgrepignore — Semgrep 排除清單
# 排除測試檔案(測試中的硬編碼值是正常的)
tests/
__tests__/
*.test.js
*.spec.js

# 排除第三方產生的程式碼
node_modules/
dist/
build/
# .gitleaksignore — GitLeaks 排除清單
# 排除測試用的假密碼
test-fixtures/fake-credentials.json
# 排除已知的 false positive(附上 issue 連結)
# Issue: https://github.com/your-repo/issues/123
config/example.env

關鍵原則:
每一條排除規則都要附上原因和 Issue 連結。不要只是靜默忽略,要讓其他人知道「為什麼這不是問題」。

建議三:讓掃描結果「可見」

不要讓安全掃描結果只藏在 CI log 裡。讓它出現在團隊日常能看到的地方:

  • PR Comment:安全掃描結果自動貼在 PR 的留言區
  • Slack 通知:掃描失敗時自動通知 #security 頻道
  • Dashboard:用 GitHub Security Tab 追蹤漏洞趨勢
  # 在安全 Pipeline 中加入 Slack 通知
  notify:
    name: 📢 通知
    needs: [gate]
    runs-on: ubuntu-latest
    if: failure()
    steps:
      - name: 通知 Slack
        uses: slackapi/slack-github-action@v1
        with:
          channel-id: 'C_SECURITY'
          slack-message: |
            🚨 安全掃描失敗!
            Repository: ${{ github.repository }}
            PR: ${{ github.event.pull_request.html_url }}
            請相關開發者盡快檢視。
        env:
          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}

飛飛觀點:
安全 Pipeline 的成功不是看「擋了多少漏洞」,而是看「團隊有沒有習慣看掃描結果」。如果開發者提交 PR 後會主動檢查安全掃描有沒有通過——恭喜你,安全文化已經開始扎根了。


九、常見問題 FAQ

Q1:安全掃描會不會讓 CI 變很慢?

不會太慢。以一個中型 Node.js 專案(10 萬行程式碼)為例,各項掃描的平均耗時:npm audit 約 10-30 秒、GitLeaks 約 20-60 秒、Semgrep 約 1-3 分鐘、ESLint Security 約 30-60 秒。全部跑完約 3-5 分鐘。而且這四項可以平行執行(GitHub Actions 的 jobs 預設就是平行的),所以總耗時約等於最慢的那一項。相比整條 CI Pipeline 通常 10-20 分鐘的總耗時,安全掃描只佔了一小部分。

Q2:我的專案不是 Node.js,這些工具還能用嗎?

大部分都能。Semgrep 支援 Python、Java、Go、Ruby、PHP 等 20 多種語言。GitLeaks 不看語言,它掃的是 Git commit 中的機密模式。Trivy 支援所有容器映像。你只需要把 <code>npm audit</code> 替換成你語言對應的 SCA 工具即可,例如 Python 用 <code>safety</code> 或 <code>pip-audit</code>、Java 用 <code>OWASP Dependency-Check</code>。

Q3:開源的免費工具夠用嗎?還是要買商業工具?

對大多數中小型團隊來說,免費工具完全足夠。 Semgrep + npm audit + GitLeaks + Trivy 這套組合能覆蓋 80% 以上的常見漏洞。商業工具(如 Snyk、Checkmarx)的優勢在於更低的誤報率、更好的修復建議、企業級報表和合規支援。建議先用免費工具建立基礎,等到團隊成熟、有明確需求時再評估商業工具。

Q4:Vibe Coding 時代,AI 生成的程式碼需要額外注意什麼?

AI 生成的程式碼更需要安全 Pipeline。根據實際經驗,AI 生成的程式碼常見的安全問題包括:SQL 字串拼接(不使用參數化查詢)、缺少輸入驗證、硬編碼測試用密碼或金鑰、使用已知有漏洞的舊版套件。建議把 AI 生成的程式碼視為「需要安全審查的外部貢獻」——先過安全 Pipeline 掃描,再做 Code Review。安全 Pipeline 正是 AI 時代開發的安全網。


十、結語:讓安全成為部署的「自然動作」

回到蓋房子的比喻。

沒有人會說:「交屋前的驗收檢查太麻煩了,我們跳過吧。」因為驗收不合格的房子,住進去是要出事的。

CI/CD 安全整合也是一樣。當你在 Pipeline 裡加入安全關卡,安全就不再是「上線前臨時想起來的事」,而是「每次部署都自然而然會經過的檢查站」。

最棒的是,這一切都是自動的。你不需要每次都請資安團隊手動審查,你只需要設定好規則,讓機器替你把關。開發者該做的,就是寫好程式、推上 Git——剩下的,Pipeline 會告訴你。

安全不是恐懼,而是創造的基礎。
當你的每一次部署都通過了自動化安全檢查,你就能更有信心地交付產品——因為你知道,你的房子不只漂亮,更經得起考驗。


延伸閱讀

[安全測試] 002 DAST 動態測試實戰:用 OWASP ZAP 掃描你的網站,讓漏洞無所遁形

「程式碼寫得再安全,上線後不實際攻攻看,你怎麼知道門有沒有鎖好?」
— SSDLC by 飛飛


為什麼你需要 DAST?靜態掃描抓不到的,動態掃描來補

在 SSDLC 的學習旅程中,我們已經走過了安全需求定義、安全設計、安全編碼,也認識了 OWASP Top 10 的攻防手法。現在,來到了階段四:安全驗證(Verification)——是時候真正「動手打打看」了。

延續蓋房子的比喻:前面的階段就像畫好了防震設計圖(安全設計)、用了防火建材施工(安全編碼)。但房子蓋好後,你總得找人來實際搖一搖、敲一敲、拿火燒一下,確認這些防護真的有效吧?

這就是 DAST(Dynamic Application Security Testing,動態應用程式安全測試) 在做的事。

SAST vs. DAST:一個看設計圖,一個看成品

你可能聽過 SAST(靜態分析),它是在程式碼層面找問題,就像看建築設計圖找結構缺陷。而 DAST 則是在系統實際運行的狀態下,從外部發動攻擊,看看能不能打進去。

比較項目 SAST(靜態分析) DAST(動態分析)
檢測時機 開發階段,分析原始碼 測試/上線階段,攻擊運行中的系統
比喻 X 光片看骨骼結構 請專業小偷實際測試防盜系統
需要原始碼? 需要 不需要(黑箱測試)
能找到的問題 程式碼邏輯缺陷、不安全的寫法 實際可被利用的漏洞、設定錯誤
找不到的問題 執行環境的設定問題 程式碼內部邏輯(看不到原始碼)
誤報率 較高(不確定是否真的能被利用) 較低(真的打進去了才算)
代表工具 SonarQube、Semgrep OWASP ZAP、Burp Suite

飛飛觀點:
SAST 和 DAST 不是二選一,而是互補。SAST 像是你的家庭醫生做健檢,DAST 像是找專業駭客做滲透測試。兩個都做,才能最大程度確保系統安全。在 SSDLC 裡,我們建議兩者都整合進 CI/CD Pipeline


什麼是 OWASP ZAP?免費又強大的安全掃描神器

ZAP(Zed Attack Proxy) 是全世界最廣泛使用的網頁應用程式安全掃描器。它是開源免費的,由社群持續維護,是 GitHub Top 1000 專案之一。目前由 Checkmarx 贊助維護,最新穩定版本為 2.17.0。

用一句話解釋 ZAP 在做什麼:它模擬駭客的行為,自動對你的網站發動各種攻擊,然後告訴你哪裡有漏洞。

ZAP 的核心運作原理:中間人代理

ZAP 的工作方式就像一個「翻譯官」,站在你的瀏覽器和網站之間:

你的瀏覽器 ←→ ZAP(攔截、分析、修改請求) ←→ 你的網站

就像便利商店的監視器——所有進出的人(HTTP 請求與回應)都會被記錄下來。ZAP 不只記錄,還會分析這些流量,找出可疑的安全問題。

ZAP 能幫你找到什麼?

ZAP 的掃描規則涵蓋了 OWASP Top 10 的大部分類別:

OWASP Top 10 類別 ZAP 能偵測的範例
A01 Broken Access Control 目錄遍歷、敏感檔案暴露
A02 Security Misconfiguration 缺少安全標頭、錯誤頁面洩漏資訊
A03 Software Supply Chain 使用已知有漏洞的 JavaScript 函式庫
A05 Injection SQL Injection、XSS、OS Command Injection
A07 Authentication Failures 弱密碼、Session 管理問題
A08 Integrity Failures 缺少 SRI 標籤
A09 Logging & Alerting 無法直接檢測(需要人工審查)

安裝 OWASP ZAP:三種方式任你選

方式一:桌面版安裝(推薦新手)

最直覺的方式,有圖形化介面,適合學習和手動探索。

系統需求: Java 17 或更高版本

ZAP 官方下載頁面 下載對應作業系統的安裝檔:

作業系統 安裝方式
Windows 下載 .exe 安裝檔,或用 <code>winget install ZAP</code>
macOS 下載 .dmg,拖到 Applications
Linux 下載 .tar.gz,或用 <code>flatpak install flathub org.zaproxy.ZAP</code>

方式二:Docker 安裝(推薦 CI/CD 整合)

不需要安裝 Java,一行指令搞定,最適合自動化:

# 拉取穩定版映像
docker pull zaproxy/zap-stable

# 或是拉取精簡版(CI 環境專用,體積更小)
docker pull ghcr.io/zaproxy/zaproxy:bare

方式三:套件管理器

# macOS(Homebrew)
brew install --cask zap

# Windows(Scoop)
scoop install extras/zap

# Windows(Chocolatey)
choco install zap

飛飛觀點:
如果你是第一次接觸 DAST,建議先用桌面版玩一玩,熟悉介面和概念後,再學 Docker 版做自動化。就像學開車,先在練習場熟悉方向盤,再上高速公路。


ZAP 基本操作:從零開始掃描你的第一個網站

重要提醒:只掃描你有權限的系統

在開始之前,最重要的事情:

絕對不要對沒有授權的網站執行安全掃描。 這不只是道德問題,還可能觸犯法律。就像你不能因為「想測試防盜系統」就去撬別人家的門。

安全的練習環境:

練習平台 說明 網址
OWASP Juice Shop 故意有漏洞的電商網站 https://owasp.org/www-project-juice-shop/
OWASP WebGoat 互動式資安教學平台 https://owasp.org/www-project-webgoat/
DVWA 經典的漏洞練習平台 https://github.com/digininja/DVWA
nodelab.feifei.tw 飛飛的 Node.js 練習靶站 https://nodelab.feifei.tw

以下示範,我們用 OWASP Juice Shop 作為掃描目標。

Step 1:啟動練習靶站

# 用 Docker 啟動 Juice Shop
docker run -d -p 3000:3000 bkimminich/juice-shop

# 確認靶站運行中
# 打開瀏覽器,連到 http://localhost:3000

Step 2:啟動 ZAP 並設定掃描目標

打開 ZAP 桌面版後,你會看到一個歡迎畫面,選擇「Automated Scan」(自動掃描)。

在「URL to attack」欄位輸入:

http://localhost:3000

Step 3:理解 ZAP 的三種掃描模式

ZAP 提供三種掃描模式,就像檢查房子安全的三種力道:

掃描模式 做了什麼 風險程度 適用場景
Spider(爬蟲) 自動探索網站的所有連結和頁面 低(只是瀏覽) 了解網站結構
Passive Scan(被動掃描) 分析經過 ZAP 的流量,不主動攻擊 低(只看不打) 快速找出明顯問題
Active Scan(主動掃描) 對目標發動實際攻擊測試 高(會送惡意 payload) 找出可被利用的漏洞

建議順序:

Spider → Passive Scan → Active Scan
先探索 → 再觀察 → 最後動手打

飛飛觀點:
被動掃描隨時可以跑,它就像站在路邊觀察交通流量。但主動掃描要小心,它會真的發送攻擊請求——所以千萬不要對正式環境的生產系統跑主動掃描,除非你有明確的授權和風險評估。

Step 4:執行自動掃描

在 ZAP 的 Quick Start 標籤中:

  1. 輸入目標 URL:<code>http://localhost:3000</code>
  2. 點擊「Attack」
  3. ZAP 會依序執行:Spider → Passive Scan → Active Scan

掃描過程中,你可以在下方的面板看到即時進度:

  • Spider 標籤:顯示發現的 URL 數量
  • Active Scan 標籤:顯示掃描進度百分比
  • Alerts 標籤:顯示已發現的安全問題

掃描報告解讀:看懂 ZAP 告訴你的事

掃描完成後,ZAP 會產生一份包含所有發現的報告。讓我們學會怎麼看。

Alert 的風險等級

ZAP 用四種顏色標示風險等級:

風險等級 顏色 說明 處理優先順序
High(高) 🔴 紅色 可被直接利用的嚴重漏洞 立即修復
Medium(中) 🟠 橘色 有潛在風險但利用條件較複雜 盡快修復
Low(低) 🟡 黃色 資訊洩漏或最佳實踐未遵循 排程修復
Informational(資訊) 🔵 藍色 參考資訊,不一定是漏洞 評估後決定

常見的掃描發現與修復建議

以下是對 Juice Shop 或一般 Node.js 網站掃描時最常看到的問題:

🔴 High:SQL Injection

ZAP 發現什麼: 在搜尋功能或登入表單中,注入 SQL 語句後得到異常回應。

修復方式:

// ❌ 危險:字串拼接
const query = <code>SELECT * FROM products WHERE name = '${userInput}'</code>;

// ✅ 安全:參數化查詢
const query = 'SELECT * FROM products WHERE name = $1';
const result = await pool.query(query, [userInput]);

🔴 High:Cross-Site Scripting(XSS)

ZAP 發現什麼: 在輸入欄位注入 <code><script>alert(1)</script></code> 後,腳本被執行。

修復方式:

// ✅ 使用輸出編碼
const he = require('he');
const safeOutput = he.encode(userInput);

// ✅ 使用 DOMPurify 清理 HTML
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const DOMPurify = createDOMPurify(new JSDOM('').window);
const cleanHTML = DOMPurify.sanitize(dirtyInput);

🟠 Medium:Missing Security Headers

ZAP 發現什麼: HTTP 回應缺少關鍵的安全標頭。

修復方式:

const helmet = require('helmet');
app.use(helmet());

// 或手動設定
app.use((req, res, next) => {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  res.setHeader('Content-Security-Policy', "default-src 'self'");
  next();
});

🟡 Low:Cookie Without Secure Flag

ZAP 發現什麼: Session Cookie 沒有設定 <code>Secure</code> 和 <code>HttpOnly</code> 旗標。

修復方式:

app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: {
    secure: true,      // 只在 HTTPS 傳送
    httpOnly: true,     // JavaScript 無法存取
    sameSite: 'strict', // 防止 CSRF
    maxAge: 3600000     // 1 小時過期
  }
}));

🔵 Informational:Application Error Disclosure

ZAP 發現什麼: 錯誤頁面暴露了框架版本、堆疊追蹤等內部資訊。

修復方式:

// ✅ 自訂錯誤處理,不洩漏內部資訊
app.use((err, req, res, next) => {
  // 記錄完整錯誤到日誌(內部使用)
  logger.error('Application Error', { 
    error: err.message, 
    stack: err.stack 
  });

  // 回傳給使用者的只有通用訊息
  res.status(500).json({ 
    error: '系統處理時發生錯誤,請稍後再試' 
  });
});

// ✅ 在 Express 中隱藏框架資訊
app.disable('x-powered-by');

處理誤報(False Positives)

ZAP 不是完美的,它有時候會報告「假警報」。處理誤報的步驟:

  1. 手動驗證:對 ZAP 標記的漏洞,嘗試手動重現攻擊
  2. 分析上下文:看看這個 Alert 在你的系統中是否真的構成風險
  3. 標記為誤報:在 ZAP 中將確認的誤報標記為「False Positive」,避免下次重複出現
  4. 記錄原因:記下為什麼判定為誤報,方便團隊日後參考

產出掃描報告:給團隊和主管看的安全報告

從 ZAP 桌面版匯出報告

在 ZAP 中,選擇「Report」→「Generate Report」:

報告格式 適用對象 說明
HTML 團隊內部分享 最直觀,可在瀏覽器開啟
JSON 自動化處理 適合程式解析、整合到其他系統
XML 合規稽核 標準格式,適合提交給稽核單位
Markdown 文件紀錄 可直接放進 Git 做版本控制

從 Docker 版產出報告

# Baseline Scan(被動掃描,適合 CI/CD)
docker run -v $(pwd):/zap/wrk/:rw -t zaproxy/zap-stable \
  zap-baseline.py \
  -t http://your-staging-site.com \
  -r baseline-report.html

# Full Scan(完整掃描,含主動攻擊)
docker run -v $(pwd):/zap/wrk/:rw -t zaproxy/zap-stable \
  zap-full-scan.py \
  -t http://your-staging-site.com \
  -r full-scan-report.html

# API Scan(API 專用掃描)
docker run -v $(pwd):/zap/wrk/:rw -t zaproxy/zap-stable \
  zap-api-scan.py \
  -t http://your-staging-site.com/swagger.json \
  -f openapi \
  -r api-report.html

飛飛觀點:
報告不是掃完就丟的。好的做法是每次掃描完,把報告存進 Git,追蹤漏洞數量的趨勢變化。如果 High 和 Medium 的數量持續下降,就代表你們的安全做得越來越好——這就是用數據證明安全投資的價值。


自動化掃描:把 ZAP 整合進 CI/CD Pipeline

手動掃描很好,但我們的目標是讓安全檢查自動化——每次部署前自動跑掃描,發現問題就阻擋部署。

GitHub Actions 整合範例

name: DAST Security Scan

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  dast-scan:
    runs-on: ubuntu-latest

    services:
      # 啟動你的應用程式
      webapp:
        image: your-app:latest
        ports:
          - 3000:3000

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # 等待應用程式啟動
      - name: Wait for app
        run: |
          for i in $(seq 1 30); do
            curl -s http://localhost:3000 && break
            echo "Waiting for app to start... ($i/30)"
            sleep 2
          done

      # ZAP Baseline Scan(被動掃描,速度快)
      - name: ZAP Baseline Scan
        uses: zaproxy/action-baseline@v0.14.0
        with:
          target: 'http://localhost:3000'
          rules_file_name: '.zap/rules.tsv'
          cmd_options: '-a'

      # 上傳掃描報告
      - name: Upload Report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: zap-report
          path: report_html.html

自訂掃描規則:控制哪些 Alert 該擋、哪些該放

建立 <code>.zap/rules.tsv</code> 檔案,定義每條規則的處理方式:

10011   IGNORE  (Cookie Without Secure Flag - 開發環境可忽略)
10015   IGNORE  (Incomplete or No Cache-control - 非安全關鍵)
10021   WARN    (X-Content-Type-Options Header Missing)
10038   WARN    (Content Security Policy Header Not Set)
40012   FAIL    (Cross Site Scripting - Reflected - 必須阻擋)
40014   FAIL    (Cross Site Scripting - Persistent - 必須阻擋)
40018   FAIL    (SQL Injection - 必須阻擋)
90022   FAIL    (Application Error Disclosure - 不應洩漏資訊)
  • FAIL:發現就阻擋部署(Pipeline 失敗)
  • WARN:發出警告但不阻擋
  • IGNORE:完全忽略(確認的誤報或可接受的風險)

ZAP Automation Framework:進階自動化

ZAP 的 Automation Framework 是官方推薦的自動化方式,用一個 YAML 檔案控制所有掃描行為:

# zap-automation.yaml
env:
  contexts:
    - name: "My App"
      urls:
        - "http://localhost:3000"
      includePaths:
        - "http://localhost:3000.*"
      excludePaths:
        - "http://localhost:3000/logout"

jobs:
  # Step 1: 被動掃描規則設定
  - type: passiveScan-config
    parameters:
      maxAlertsPerRule: 10
      scanOnlyInScope: true

  # Step 2: 爬蟲探索
  - type: spider
    parameters:
      context: "My App"
      maxDuration: 5  # 最多跑 5 分鐘

  # Step 3: 等待被動掃描完成
  - type: passiveScan-wait
    parameters:
      maxDuration: 10

  # Step 4: 主動掃描
  - type: activeScan
    parameters:
      context: "My App"
      maxRuleDurationInMins: 5
      maxScanDurationInMins: 30

  # Step 5: 產出報告
  - type: report
    parameters:
      template: "traditional-html"
      reportDir: "/zap/wrk/"
      reportFile: "zap-report.html"
    risks:
      - high
      - medium
      - low

用 Docker 執行:

docker run -v $(pwd):/zap/wrk/:rw -t zaproxy/zap-stable \
  zap.sh -cmd -autorun /zap/wrk/zap-automation.yaml

實戰案例:台灣電商平台的 DAST 掃描流程

場景

你的團隊正在為一家台灣電商平台的會員系統做上線前的安全驗證。系統使用 Node.js + Express + PostgreSQL,功能包含會員註冊、登入、商品瀏覽、購物車、結帳。

Step 1:規劃掃描範圍

## DAST 掃描計畫

### 掃描目標
- 測試環境:https://staging.your-ecommerce.com.tw
- 掃描時間:週末凌晨(避免影響測試環境使用者)
- 掃描工具:OWASP ZAP 2.17.0(Docker 版)

### 掃描範圍
✅ 包含:
- 會員註冊/登入/登出
- 商品搜尋與瀏覽
- 購物車操作
- 結帳流程(測試用信用卡)
- 會員資料修改
- API 端點(/api/v1/*)

❌ 排除:
- 第三方金流回呼(避免影響金流服務商)
- /admin/* 管理後台(另案處理)
- /healthcheck(監控端點)

### 掃描模式
1. Spider + Passive Scan(第一輪)
2. Active Scan(第二輪,針對高風險功能)
3. API Scan(針對 Swagger 定義的 API)

Step 2:執行掃描並記錄發現

假設掃描後得到以下結果:

風險等級 數量 關鍵發現
🔴 High 2 SQL Injection(商品搜尋)、Reflected XSS(會員暱稱)
🟠 Medium 5 缺少 CSP 標頭、CORS 設定過於寬鬆、Session Cookie 未設 SameSite
🟡 Low 8 X-Powered-By 洩漏框架資訊、Cookie 缺少 Secure flag
🔵 Info 12 各項資訊性發現

Step 3:建立修復追蹤表

| 編號 | 風險等級 | 漏洞類型 | 影響範圍 | 負責人 | 修復期限 | 狀態 |
|------|---------|---------|---------|--------|---------|------|
| V-001 | High | SQL Injection | 商品搜尋 API | 後端工程師 A | 3 天內 | 🔧 修復中 |
| V-002 | High | Reflected XSS | 會員暱稱顯示 | 前端工程師 B | 3 天內 | 🔧 修復中 |
| V-003 | Medium | Missing CSP | 全站 | DevOps C | 1 週內 | ⏳ 待處理 |
| V-004 | Medium | CORS 過寬 | API 端點 | 後端工程師 A | 1 週內 | ⏳ 待處理 |

Step 4:修復後重新掃描

修復完成後,一定要重新掃描,確認漏洞確實被修復了。這就像修完水管後要重新開水測試一樣。

# 修復後的驗證掃描
docker run -v $(pwd):/zap/wrk/:rw -t zaproxy/zap-stable \
  zap-baseline.py \
  -t https://staging.your-ecommerce.com.tw \
  -r verification-scan-report.html

DAST 掃描 Checklist:團隊可直接使用

## DAST 掃描 Checklist

### 掃描前
- [ ] 已取得目標系統的掃描授權
- [ ] 掃描目標為測試/Staging 環境(非生產環境)
- [ ] 已確認掃描範圍(包含/排除的 URL)
- [ ] 已通知相關團隊掃描時程
- [ ] 測試資料已準備好(測試帳號、測試信用卡等)

### 掃描中
- [ ] Spider 探索完成,確認覆蓋率
- [ ] Passive Scan 完成,初步檢視結果
- [ ] Active Scan 完成(注意是否影響目標系統效能)
- [ ] API Scan 完成(如有 Swagger/OpenAPI 定義)

### 掃描後
- [ ] 匯出掃描報告(HTML + JSON)
- [ ] 逐一檢視 High 和 Medium 的 Alert
- [ ] 排除誤報(False Positive),記錄判斷原因
- [ ] 建立漏洞修復追蹤表
- [ ] 將報告存入版本控制(Git)
- [ ] 通知開發團隊修復發現的漏洞
- [ ] 排定修復後的驗證掃描時間

團隊落地建議:讓 DAST 變成日常

建議一:分階段導入,別一次到位

階段 做什麼 工具設定
Week 1-2 手動在 Staging 環境跑 ZAP Baseline Scan ZAP 桌面版
Week 3-4 把 Baseline Scan 加入 CI/CD GitHub Actions + ZAP Docker
Month 2 加入 Active Scan(僅限 Staging) ZAP Automation Framework
Month 3 建立自訂規則和誤報管理流程 rules.tsv + 漏洞追蹤表
持續 每季做一次完整的手動滲透測試 ZAP + 專業資安團隊

建議二:從 Baseline Scan 開始,不要一開始就跑 Full Scan

Baseline Scan 只做被動掃描,速度快(通常幾分鐘內完成)、風險低(不會對目標系統造成影響),非常適合放在每次 PR 或部署前自動執行。

Full Scan 和 Active Scan 因為會實際發動攻擊,建議:

  • 只在 Staging 環境 執行
  • 安排在 非尖峰時段 執行
  • 第一次跑的時候 有人監控 系統狀態

建議三:把 DAST 結果和 SAST 結果關聯起來

當 ZAP 發現一個 SQL Injection 漏洞時,對應回去看 SAST 工具有沒有在同一個位置標記問題。如果 SAST 沒抓到但 DAST 抓到了,代表你的 SAST 規則可能需要調整。反之亦然。

情境 代表的意義 行動
SAST ✅ DAST ✅ 兩者都找到,很好 修復漏洞
SAST ❌ DAST ✅ SAST 漏掉了 修復漏洞 + 調整 SAST 規則
SAST ✅ DAST ❌ 可能是 SAST 誤報 手動驗證
SAST ❌ DAST ❌ 沒問題…或兩個都漏掉了 考慮搭配滲透測試

建議四:建立「安全品質門檻」

在 CI/CD Pipeline 中設定明確的門檻——什麼等級的漏洞會阻擋部署:

# 安全品質門檻定義
security_gate:
  block_deployment:
    - high_risk_count > 0      # 有任何 High 風險就擋
    - medium_risk_count > 5    # Medium 超過 5 個就擋
  warn_only:
    - low_risk_count > 20      # Low 超過 20 個發警告
    - new_alerts > 0           # 有任何新發現就通知

常見問題 FAQ

Q1:ZAP 掃描要跑多久?會不會影響系統效能?

Baseline Scan(被動掃描)通常幾分鐘內完成,對系統效能影響很小。Full Scan(含主動掃描)時間取決於網站大小,小型網站可能 30 分鐘到 1 小時,大型網站可能需要數小時。主動掃描會產生大量請求,建議在 Staging 環境執行,並在非尖峰時段進行。

Q2:ZAP 跟 Burp Suite 比,該選哪個?

ZAP 是開源免費的,功能已經非常強大,對於大部分團隊來說綽綽有餘。Burp Suite Professional 是付費工具(年費約 USD $449),在某些進階功能上更強(如更好的爬蟲引擎、Intruder 模組)。如果你是資安新手或預算有限,從 ZAP 開始絕對沒問題。如果團隊有專職的滲透測試人員,可以考慮 Burp Suite Professional 作為進階工具。

Q3:ZAP 掃不到某些頁面怎麼辦?

ZAP 的 Spider 有時候無法探索到需要登入才能看到的頁面,或是前端框架(如 React、Vue)動態生成的內容。解決方案:

  • 登入問題:使用 ZAP 的 Authentication 設定,讓 ZAP 能自動登入
  • 前端框架問題:使用 ZAP 2.16.0+ 的 Client Spider,它能更好地處理 JavaScript 驅動的頁面
  • 手動探索:先手動瀏覽網站(透過 ZAP Proxy),讓 ZAP 記錄所有頁面,再執行主動掃描

Q4:掃描結果一大堆 Alert,怎麼分辨哪些是真的、哪些是誤報?

先從 High 風險開始看,數量通常不多。對每個 High Alert,用 ZAP 的「Resend」功能手動重送攻擊請求,看看回應是否真的表示漏洞存在。如果你不確定,可以請教資安同事或在 OWASP 社群發問。Medium 和 Low 的 Alert 可以先建立一份清單,逐步處理。

Q5:我的團隊只有開發人員,沒有資安專家,適合用 ZAP 嗎?

完全適合。ZAP 的設計目標之一就是讓開發人員也能做基本的安全測試。Baseline Scan 幾乎不需要安全知識就能執行和解讀結果。隨著團隊經驗累積,可以慢慢學習 Active Scan 和更進階的功能。記住,有做總比沒做好——就算只是跑一次 Baseline Scan,你已經比大多數團隊做得多了。


結語:安全測試不是找碴,而是找到讓系統更強的機會

很多開發者聽到「安全掃描」會緊張,覺得好像是要來挑自己程式碼的毛病。但換個角度想——DAST 就像你的系統的「健康檢查」。你不會因為醫生發現你膽固醇偏高就生醫生的氣,反而會感謝他提早發現問題,讓你有機會調整。

OWASP ZAP 就是你的系統醫生。它免費、開源、社群活躍,從個人專案到企業級應用都能勝任。

回到蓋房子的比喻——你的房子蓋好了,安裝了防盜門、監視器、消防設備。但你得真正測試一下這些設備有沒有用。DAST 就是幫你做這件事的。

安全不是恐懼,而是創造的基礎。
當你知道系統經過了真實的攻擊測試,你就能更有信心地面對使用者——因為你知道,你的房子不只看起來安全,而是真的安全。


延伸閱讀

[安全測試] 001 SAST 靜態分析入門:讓工具幫你審程式碼——SonarQube、Semgrep 工具使用與 IDE 整合實戰

「人的眼睛會累、會遺漏,但工具不會。
靜態分析不是取代 Code Review,而是幫你在 Review 之前,先過濾掉那些不該犯的錯。」
— SSDLC by 飛飛


一、SAST 是什麼?為什麼它是你的「自動驗屋師」?

在 SSDLC 的蓋房子旅程中,我們已經走過了安全需求定義(確認要防什麼)、安全設計(畫好藍圖、標好消防通道)、安全實作(用防火建材施工、學會輸入驗證與輸出編碼、熟悉 OWASP Top 10)。

現在,我們來到了階段四:安全驗證(Verification)——房子蓋好了,要請結構技師來檢查。

想像你花了半年蓋好一棟房子。交屋前,你會做什麼?當然是請專業的驗屋師來檢查——看看牆壁有沒有裂縫、水管有沒有漏水、電線有沒有接錯。你不會等住進去之後才發現問題,對吧?

SAST(Static Application Security Testing,靜態應用程式安全測試)就是軟體世界的「自動驗屋師」。 它不需要把程式跑起來,直接看你的原始碼,找出裡面潛藏的安全漏洞。

跟動態測試(DAST)不同的是——DAST 是把系統跑起來然後從外面攻擊它(像小偷試著破門而入),而 SAST 是打開牆壁看裡面的結構(像 X 光檢查)。兩者互補,但 SAST 的最大優勢是:你可以在程式碼還沒部署之前就抓到問題

比較項目 SAST(靜態分析) DAST(動態分析)
比喻 X 光檢查建築結構 派小偷來試著破門而入
檢查時機 寫程式時 / CI/CD 階段 系統部署後
需要執行程式嗎? 不需要 需要
能看到原始碼嗎? 能(白箱) 不能(黑箱)
擅長找什麼? SQL Injection、XSS、硬編碼金鑰、不安全的函式呼叫 認證問題、設定錯誤、運行時漏洞
誤報率 偏高(但可調整) 偏低
修復成本 低(問題還在開發者手上) 高(可能要改架構)

飛飛觀點:
我常跟團隊說,SAST 就像開車上路前的車輛檢查。你不會等車子在高速公路上拋錨才想到要檢查煞車吧?SAST 讓你在程式碼還在「車庫」的時候就把問題抓出來——修起來又快又便宜。


二、SAST 能幫你找到什麼?常見漏洞全覽

很多開發者聽到「靜態分析」,第一反應是:「不就是 Lint 嗎?ESLint 我早就在用了。」

ESLint 確實是靜態分析的一種,但它主要關注的是程式碼風格和基本錯誤。安全導向的 SAST 工具關注的是完全不同的維度——它們在找的是可以被攻擊者利用的漏洞。

以下是 SAST 工具常見的檢測類別,對應到我們之前學過的 OWASP Top 10:

漏洞類別 SAST 能偵測的範例 對應 OWASP Top 10
注入攻擊 SQL 字串拼接、Command Injection A05: Injection
跨站腳本 未編碼的使用者輸入直接輸出到 HTML A05: Injection
硬編碼機敏資料 密碼、API Key 直接寫在程式碼裡 A04: Cryptographic Failures
不安全的加密 使用 MD5/SHA1 雜湊密碼、弱加密演算法 A04: Cryptographic Failures
路徑穿越 使用者輸入直接拼接檔案路徑 A01: Broken Access Control
不安全的反序列化 直接反序列化不受信任的資料 A08: Software or Data Integrity
不安全的正規表達式 ReDoS(正規表達式阻斷服務)風險 A10: Mishandling of Exceptional Conditions
缺少安全標頭 未設定 CORS、CSP 等安全標頭 A02: Security Misconfiguration

讓我們看一個真實的例子。以下這段 Node.js 程式碼,你能看出幾個問題?

// ❌ 這段程式碼有多少安全問題?
const express = require('express');
const mysql = require('mysql');
const app = express();

const DB_PASSWORD = 'MyS3cretP@ss!';  // 問題 1

app.get('/user', (req, res) => {
  const userId = req.query.id;
  const query = <code>SELECT * FROM users WHERE id = '${userId}'</code>;  // 問題 2

  connection.query(query, (err, results) => {
    if (err) {
      res.status(500).send(<code class="kb-btn">Error: ${err.message}</code>);  // 問題 3
      return;
    }
    res.send(results);  // 問題 4
  });
});

人工 Code Review 可能會漏掉一兩個,但 SAST 工具會一次全部標出來:

  1. 硬編碼密碼(<code>DB_PASSWORD</code> 直接寫在程式碼裡)
  2. SQL Injection(字串拼接組合 SQL 查詢)
  3. 錯誤資訊洩露(把資料庫錯誤訊息直接回傳給使用者)
  4. 過度資料暴露(回傳整個 <code>results</code> 物件,可能包含敏感欄位)

這就是 SAST 的價值——它不會累、不會忘記、不會因為趕 deadline 就放水


三、工具選擇:SonarQube vs. Semgrep

市面上 SAST 工具百百種,但對於台灣的中小型開發團隊來說,有兩個工具特別值得認識:SonarQubeSemgrep。它們各有特色,適合不同的使用情境。

3.1 SonarQube:程式碼品質與安全的「全科醫生」

SonarQube 就像一位全科醫生——不只看安全漏洞,還會幫你檢查程式碼品質、重複程式碼、測試覆蓋率等整體健康狀況。

特色:

  • 支援 30+ 種程式語言
  • 提供 Web 介面的儀表板,視覺化呈現程式碼健康狀況
  • 內建「品質門檻(Quality Gate)」機制,可以自動阻擋不達標的程式碼
  • Community Build 版本免費,適合中小團隊入門
  • 支援 IDE 外掛(SonarQube for IDE,支援 VS Code、JetBrains、Cursor 等)

適合場景:

  • 想要一個「一站式」的程式碼品質管理平台
  • 團隊有自己的伺服器或 Docker 環境可以部署
  • 需要追蹤長期的程式碼品質趨勢

快速啟動(Docker):

# 使用 Docker 快速啟動 SonarQube Community Build
docker run -d --name sonarqube \
  -p 9000:9000 \
  sonarqube:community

# 啟動後開啟瀏覽器 http://localhost:9000
# 預設帳號密碼:admin / admin(首次登入會要求修改)

在 Node.js 專案中使用 SonarQube Scanner:

# 安裝 SonarQube Scanner
npm install -D sonarqube-scanner
// sonar-project.js — SonarQube 掃描設定
const sonarqubeScanner = require('sonarqube-scanner').default;

sonarqubeScanner(
  {
    serverUrl: 'http://localhost:9000',
    token: process.env.SONAR_TOKEN,  // 從環境變數讀取 Token
    options: {
      'sonar.projectKey': 'my-nodejs-app',
      'sonar.projectName': 'My Node.js App',
      'sonar.sources': 'src',
      'sonar.tests': 'tests',
      'sonar.javascript.lcov.reportPaths': 'coverage/lcov.info',
      'sonar.exclusions': 'node_modules/**,coverage/**,dist/**',
    },
  },
  () => process.exit()
);
// package.json — 加入掃描指令
{
  "scripts": {
    "sonar": "node sonar-project.js",
    "test:coverage": "jest --coverage",
    "security:scan": "npm run test:coverage && npm run sonar"
  }
}

3.2 Semgrep:輕量、快速的「安全專科醫生」

如果 SonarQube 是全科醫生,Semgrep 就是安全專科醫生——它更專注在安全漏洞的偵測,而且規則的撰寫方式非常直覺,就像在寫程式碼一樣。

特色:

  • 開源免費(Community Edition),商業版提供 AI 輔助分類
  • 規則語法像程式碼,開發者容易理解和自訂
  • 不需要編譯,掃描速度極快
  • 內建 OWASP Top 10、CWE Top 25 等規則集
  • 支援 30+ 種程式語言
  • 2025 年入選 Gartner Magic Quadrant for Application Security Testing

適合場景:

  • 想要快速在 CI/CD 中加入安全掃描
  • 團隊偏好命令列工具,不想架設額外伺服器
  • 需要自訂安全規則來符合公司特殊需求
  • 想要針對 OWASP Top 10 做專項掃描

快速啟動:

# 安裝 Semgrep(需要 Python 3.8+)
pip install semgrep

# 或使用 Homebrew(macOS)
brew install semgrep

# 用 OWASP Top 10 規則掃描目前的專案
semgrep --config p/owasp-top-ten .

# 用 Node.js 安全規則掃描
semgrep --config p/nodejs .

# 同時使用多個規則集
semgrep --config p/owasp-top-ten --config p/nodejs --config p/secrets .

Semgrep 規則長什麼樣?

這是 Semgrep 最酷的地方——它的規則長得就像程式碼:

# custom-rules/no-sql-string-concat.yml
rules:
  - id: sql-string-concatenation
    patterns:
      - pattern: |
          $QUERY = <code>...${$USER_INPUT}...</code>
      - pattern-not: |
          $QUERY = <code>...${$SAFE_VALUE}...</code>
    message: |
      偵測到 SQL 字串拼接,可能導致 SQL Injection。
      請使用參數化查詢(Parameterized Query)代替。
    languages: [javascript, typescript]
    severity: ERROR
    metadata:
      owasp: A05:2025 Injection
      cwe: CWE-89
      fix: 使用 $1 placeholder 和參數陣列
# 使用自訂規則掃描
semgrep --config custom-rules/ .

3.3 SonarQube vs. Semgrep:怎麼選?

比較維度 SonarQube Community Build Semgrep Community Edition
主要用途 程式碼品質 + 安全 專注安全掃描
部署方式 需要伺服器(Docker/實體機) 命令列工具,免部署
Web 介面 有(功能完整的儀表板) 有(Semgrep Cloud,可選用)
自訂規則 較複雜(Java 撰寫或 XML 設定) 簡單直覺(YAML,像寫程式碼)
掃描速度 中等(需建立索引) 快(不需編譯)
CI/CD 整合 需要額外設定 Scanner 原生支援,一行指令搞定
品質門檻 內建(Quality Gate) 需自行設定(exit code)
適合團隊 中大型、有 DevOps 資源 任何規模、快速導入
費用 Community Build 免費 Community Edition 免費

飛飛觀點:
我的建議是:先用 Semgrep 快速上手,再用 SonarQube 做長期管理。Semgrep 可以在五分鐘內跑完第一次掃描,讓團隊立刻看到效果;SonarQube 則適合建立長期的品質追蹤機制。兩者不衝突,很多團隊會同時使用——Semgrep 在 PR 階段即時回饋,SonarQube 在後台追蹤整體趨勢。


四、實戰演練:從零開始掃描你的 Node.js 專案

讓我們用一個台灣電商系統的情境,從頭到尾走一遍 SAST 的流程。

4.1 準備一個有漏洞的範例專案

假設你正在開發一個台灣電商平台的會員系統,以下是幾個有安全問題的檔案:

// src/controllers/userController.js — 有多個安全問題的範例
const db = require('../db');
const jwt = require('jsonwebtoken');

const JWT_SECRET = 'feifei-super-secret-key-2025';  // 🚨 硬編碼金鑰

// 會員登入
async function login(req, res) {
  const { email, password } = req.body;

  // 🚨 SQL Injection
  const query = <code>SELECT * FROM users WHERE email = '${email}' AND password = '${password}'</code>;
  const user = await db.query(query);

  if (user.length === 0) {
    return res.status(401).json({ error: '帳號或密碼錯誤' });
  }

  const token = jwt.sign({ userId: user[0].id, role: user[0].role }, JWT_SECRET);
  res.json({ token, user: user[0] });  // 🚨 回傳整個 user 物件(含密碼)
}

// 查詢會員資料
async function getUser(req, res) {
  const userId = req.params.id;  // 🚨 沒有驗證 userId 是否為數字

  try {
    const user = await db.query(<code class="kb-btn">SELECT * FROM users WHERE id = ${userId}</code>);
    res.json(user[0]);
  } catch (err) {
    res.status(500).json({ error: err.message, stack: err.stack });  // 🚨 洩露錯誤堆疊
  }
}

// 檔案下載
async function downloadInvoice(req, res) {
  const filename = req.query.file;
  const filepath = <code class="kb-btn">./invoices/${filename}</code>;  // 🚨 Path Traversal
  res.sendFile(filepath);
}

module.exports = { login, getUser, downloadInvoice };

4.2 用 Semgrep 掃描

# 第一步:安裝 Semgrep
pip install semgrep --break-system-packages

# 第二步:用 OWASP Top 10 規則掃描
semgrep --config p/owasp-top-ten --config p/nodejs src/

# 輸出結果範例(簡化版):
# ┌──────────────────────────────────────────────────────────┐
# │ Findings                                                  │
# ├──────────────────────────────────────────────────────────┤
# │ src/controllers/userController.js                         │
# │                                                          │
# │ ❌ javascript.lang.security.audit.sqli.node-sqli         │
# │    line 12: SQL injection risk from string concatenation  │
# │    Severity: ERROR                                        │
# │                                                          │
# │ ❌ javascript.lang.security.hardcoded-secret              │
# │    line 4: Hardcoded JWT secret detected                  │
# │    Severity: WARNING                                      │
# │                                                          │
# │ ❌ javascript.express.security.audit.path-traversal       │
# │    line 32: Path traversal vulnerability                  │
# │    Severity: ERROR                                        │
# │                                                          │
# │ ❌ javascript.lang.security.audit.error-disclosure        │
# │    line 27: Stack trace exposed to client                 │
# │    Severity: WARNING                                      │
# └──────────────────────────────────────────────────────────┘
# 4 findings in 1 file

4.3 用 SonarQube 掃描

# 第一步:啟動 SonarQube(如果還沒啟動)
docker run -d --name sonarqube -p 9000:9000 sonarqube:community

# 第二步:等待啟動完成後,在 Web 介面建立專案
# 開啟 http://localhost:9000
# 建立新專案 → 取得 Token

# 第三步:使用 sonar-scanner 掃描
npx sonarqube-scanner \
  -Dsonar.projectKey=taiwan-ecommerce \
  -Dsonar.sources=src \
  -Dsonar.host.url=http://localhost:9000 \
  -Dsonar.token=你的Token

掃描完成後,打開 SonarQube 的 Web 介面,你會看到一個清楚的儀表板,顯示:

  • Bugs:程式碼中的錯誤
  • Vulnerabilities:安全漏洞
  • Security Hotspots:需要人工確認的安全敏感程式碼
  • Code Smells:影響可維護性的程式碼問題
  • Coverage:測試覆蓋率

4.4 修復漏洞

根據掃描結果,讓我們修復上面的程式碼:

// src/controllers/userController.js — 修復後的安全版本
const db = require('../db');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const { param, body, query, validationResult } = require('express-validator');

// ✅ 從環境變數讀取金鑰
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET.length < 32) {
  throw new Error('JWT_SECRET 未設定或長度不足');
}

// ✅ 會員登入(修復版)
async function login(req, res) {
  const { email, password } = req.body;

  // ✅ 使用參數化查詢
  const user = await db.query(
    'SELECT id, email, password_hash, role FROM users WHERE email = $1',
    [email]
  );

  if (user.length === 0) {
    return res.status(401).json({ error: '帳號或密碼錯誤' });
  }

  // ✅ 使用 bcrypt 比對密碼雜湊
  const isValid = await bcrypt.compare(password, user[0].password_hash);
  if (!isValid) {
    return res.status(401).json({ error: '帳號或密碼錯誤' });
  }

  const token = jwt.sign(
    { userId: user[0].id, role: user[0].role },
    JWT_SECRET,
    { expiresIn: '1h' }  // ✅ 設定 Token 過期時間
  );

  // ✅ 只回傳必要欄位,不回傳密碼
  res.json({ token, user: { id: user[0].id, email: user[0].email } });
}

// ✅ 查詢會員資料(修復版)
const getUserValidation = [
  param('id').isInt({ min: 1 }).withMessage('無效的使用者 ID')
];

async function getUser(req, res) {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ error: '參數驗證失敗' });
  }

  const userId = parseInt(req.params.id, 10);

  try {
    // ✅ 參數化查詢 + 只選取必要欄位
    const user = await db.query(
      'SELECT id, email, name, created_at FROM users WHERE id = $1',
      [userId]
    );

    if (user.length === 0) {
      return res.status(404).json({ error: '查無此使用者' });
    }

    res.json(user[0]);
  } catch (err) {
    // ✅ 不洩露系統內部資訊
    console.error('Database error:', err);  // 記錄到伺服器日誌
    res.status(500).json({ error: '系統處理時發生錯誤,請稍後再試' });
  }
}

// ✅ 檔案下載(修復版)
const path = require('path');
const INVOICE_DIR = path.resolve('./invoices');

async function downloadInvoice(req, res) {
  const filename = req.query.file;

  // ✅ 白名單驗證檔名格式(只允許特定格式)
  if (!/^INV-\d{8}-\d{4}\.pdf$/.test(filename)) {
    return res.status(400).json({ error: '無效的發票檔名格式' });
  }

  // ✅ 防止 Path Traversal
  const filepath = path.join(INVOICE_DIR, filename);
  const resolvedPath = path.resolve(filepath);

  if (!resolvedPath.startsWith(INVOICE_DIR)) {
    return res.status(403).json({ error: '存取被拒絕' });
  }

  res.sendFile(resolvedPath);
}

module.exports = { login, getUser, getUserValidation, downloadInvoice };

修復完成後再跑一次掃描,確認所有問題都已解決。這就是 SAST 的工作流:掃描 → 修復 → 再掃描 → 確認乾淨


五、整合 IDE:在寫程式的當下就抓到漏洞

等到 CI/CD 才發現問題,雖然比上線後才發現好很多,但還是要回頭改。最理想的情況是——你在寫程式的當下,IDE 就直接告訴你這行有問題

就像蓋房子時,如果工人手邊有一台即時檢測儀,每放一塊磚就能知道角度對不對、強度夠不夠,那品質一定比事後全部檢查來得好。

5.1 VS Code + Semgrep 整合

# 方法一:從 VS Code 擴充功能市集安裝
# 搜尋 "Semgrep" → 安裝官方擴充功能

# 方法二:命令列安裝
code --install-extension semgrep.semgrep

安裝後,Semgrep 會在你儲存檔案時自動掃描,直接在程式碼中標示問題:

  • 🔴 紅色波浪線:嚴重安全漏洞(如 SQL Injection)
  • 🟡 黃色波浪線:安全警告(如硬編碼金鑰)
  • 💡 燈泡圖示:提供修復建議

你可以在 <code>.semgrepconfig.yml</code> 中設定預設規則:

# .semgrepconfig.yml — 專案根目錄
rules:
  - p/owasp-top-ten
  - p/nodejs
  - p/secrets

5.2 VS Code + SonarQube for IDE(原 SonarLint)

# 從 VS Code 擴充功能市集安裝
# 搜尋 "SonarQube for IDE" → 安裝官方擴充功能

SonarQube for IDE 可以獨立運行,也可以連接到你的 SonarQube Server(Connected Mode),確保 IDE 中的規則和 CI/CD 中的規則一致。

Connected Mode 設定(settings.json):

{
  "sonarlint.connectedMode.connections.sonarqube": [
    {
      "serverUrl": "http://localhost:9000",
      "token": "${env:SONAR_TOKEN}"
    }
  ],
  "sonarlint.connectedMode.project": {
    "connectionId": "localhost",
    "projectKey": "taiwan-ecommerce"
  }
}

5.3 JetBrains IDE(WebStorm/IntelliJ)整合

如果你的團隊使用 JetBrains 系列的 IDE:

  • SonarQube for IDE:直接從 Plugin Marketplace 搜尋安裝
  • Semgrep:同樣從 Plugin Marketplace 安裝

飛飛觀點:
IDE 整合是我認為 SAST 最被低估的功能。很多團隊導入 SAST 只做到 CI/CD 階段——問題發現了,但開發者要切換到 Pipeline 報告去看。IDE 即時回饋完全不同,它讓安全意識「長在手指上」——你打完一行有問題的程式碼,下一秒就看到紅色波浪線。久而久之,你根本不會再寫出那種程式碼了。


六、CI/CD 整合:讓安全掃描成為部署的門檻

IDE 掃描是「個人防線」,CI/CD 掃描是「團隊防線」。不管個人有沒有裝 IDE 外掛、有沒有在本地跑掃描,只要程式碼要進 main branch,就一定要通過安全檢查。

6.1 GitHub Actions + Semgrep

# .github/workflows/security-scan.yml
name: Security Scan

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  semgrep:
    name: SAST Scan with Semgrep
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Run Semgrep
        uses: returntocorp/semgrep-action@v1
        with:
          config: >-
            p/owasp-top-ten
            p/nodejs
            p/secrets
        env:
          SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}

6.2 GitHub Actions + SonarQube

# .github/workflows/sonarqube.yml
name: SonarQube Analysis

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  sonarqube:
    name: Code Quality & Security
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # SonarQube 需要完整的 Git 歷史

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies & run tests
        run: |
          npm ci
          npm run test:coverage

      - name: SonarQube Scan
        uses: SonarSource/sonarqube-scan-action@v5
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

      - name: Check Quality Gate
        uses: SonarSource/sonarqube-quality-gate-action@v1
        timeout-minutes: 5
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

6.3 設定品質門檻(Quality Gate)

品質門檻是 SAST 最重要的設定之一——它決定了「什麼程度的問題可以放行,什麼程度的問題必須阻擋」。

# SonarQube 建議的品質門檻
# 在 SonarQube Web 介面 → Quality Gates 中設定

New Code 條件(只檢查新增的程式碼):
  - 安全漏洞(Vulnerabilities): 0(不允許任何新漏洞)
  - Security Hotspots Reviewed: ≥ 100%(所有安全熱點都要審查)
  - 程式碼覆蓋率(Coverage): ≥ 80%
  - 重複程式碼比例(Duplications): ≤ 3%
# Semgrep 的品質門檻設定(透過 exit code)
# 在 CI/CD 中,Semgrep 預設在發現 ERROR 級別問題時會回傳非零 exit code
# 這會自動讓 CI/CD Pipeline 失敗

# 如果只想在特定嚴重度時失敗:
semgrep --config p/owasp-top-ten --severity ERROR .
# 只有 ERROR 級別的發現會導致掃描失敗

# 搭配 --error 旗標精確控制:
semgrep --config p/owasp-top-ten --error .
# 有任何發現都會失敗

飛飛觀點:
品質門檻的設定是一門藝術。設得太嚴,開發者會覺得綁手綁腳,甚至想辦法繞過;設得太鬆,等於沒設。我的建議是:對新程式碼嚴格,對舊程式碼寬容。SonarQube 的「New Code」策略就是這個精神——不要求你一次修完所有歷史債務,但新寫的程式碼一定要乾淨。


七、處理誤報:SAST 最讓人頭痛的問題

SAST 最大的缺點就是誤報(False Positive)——工具說有漏洞,但其實沒有。這就像驗屋師每次都說「這面牆不夠結實」,但你明明知道那是承重牆,設計得很好。

如果誤報太多,開發者會逐漸不信任工具,最後乾脆無視所有警告——這比不用工具還危險。

處理誤報的策略

策略一:標記為「已審查,非漏洞」

在 SonarQube 中,你可以把 Security Hotspot 標記為「Safe」;在 Semgrep 中,可以用 <code>nosemgrep</code> 注解:

// 這個 eval 是安全的,因為輸入來自信任的設定檔
// nosemgrep: javascript.lang.security.audit.eval-detected
const config = eval(trustedConfigString);

策略二:調整規則的嚴重度或排除特定規則

# .semgrepconfig.yml — 排除特定規則或路徑
exclude:
  - "tests/**"          # 測試程式碼不掃描
  - "scripts/seed.js"   # 資料填充腳本排除

策略三:逐步啟用規則

不要一次開啟所有規則。先從最重要的開始(SQL Injection、XSS、硬編碼金鑰),等團隊適應後再逐步增加。

導入階段 建議啟用的規則 預期誤報率
第一週 硬編碼金鑰 / 密碼
第二週 SQL Injection、Command Injection 低~中
第一個月 XSS、Path Traversal
第二個月 完整 OWASP Top 10
穩定期 加入自訂規則 低(已調整過)

八、SAST 導入 Checklist

以下是一份可以直接帶回團隊使用的 Checklist:

## SAST 導入 Checklist

### 工具選擇
- [ ] 已評估團隊需求(專注安全 vs. 全面品質管理)
- [ ] 已選定至少一款 SAST 工具(Semgrep / SonarQube / 其他)
- [ ] 已確認工具支援團隊使用的程式語言

### IDE 整合
- [ ] 已在開發者 IDE 中安裝對應的擴充功能
- [ ] 已設定專案級別的掃描規則設定檔
- [ ] 已測試 IDE 即時掃描功能正常運作

### CI/CD 整合
- [ ] 已在 CI/CD Pipeline 中加入 SAST 掃描步驟
- [ ] 掃描在 PR 階段執行(不只是 merge 後)
- [ ] 掃描結果會直接顯示在 PR 的 Comment 中

### 品質門檻
- [ ] 已設定品質門檻(新程式碼不允許新漏洞)
- [ ] 嚴重漏洞會自動阻擋部署
- [ ] 團隊已同意門檻的標準

### 誤報管理
- [ ] 已建立誤報的審查與標記流程
- [ ] 已排除不需要掃描的目錄(如 node_modules、tests)
- [ ] 定期(每月)回顧並調整規則

### 團隊協作
- [ ] 團隊成員已了解 SAST 的用途與限制
- [ ] 已指定 Security Champion 負責維護掃描規則
- [ ] 掃描結果的修復已納入 Sprint 的工作項目

九、常見問題 FAQ

Q1:SAST 掃描很慢,會不會拖慢 CI/CD Pipeline?

不會太慢,但需要最佳化。 Semgrep 以速度著稱,掃描一個中型 Node.js 專案通常只需要 30 秒到 2 分鐘。SonarQube 因為需要建立索引,首次掃描可能需要 5-10 分鐘,但後續增量掃描會快很多。

最佳化建議:排除不需要掃描的目錄(<code>node_modules</code>、<code>dist</code>、<code>coverage</code>),在 PR 階段只掃描變更的檔案,完整掃描留在 nightly build。

Q2:我已經在做 Code Review 了,還需要 SAST 嗎?

絕對需要。 Code Review 和 SAST 是互補的,不是替代的。Code Review 擅長發現商業邏輯問題和架構設計缺陷,SAST 擅長發現模式化的安全漏洞。人的眼睛會累、會受趕 deadline 的壓力影響,但工具不會。

反過來說,SAST 也不能取代 Code Review——它不懂你的業務邏輯,不知道「這個使用者不應該能看到那個資料」這種語境相關的問題。

Q3:SonarQube Community Build 和 Server 版本差在哪裡?

Community Build 是免費開源版本,適合中小團隊入門使用。它提供基礎的 SAST 和程式碼品質檢測功能。Server 的付費版本(Developer、Enterprise)多了進階 SAST(如 taint analysis 污點追蹤)、SCA 依賴掃描、更多語言支援、分支分析等企業功能。

對於剛開始導入 SAST 的團隊,Community Build 已經綽綽有餘了。

Q4:Vibe Coding 用 AI 生成的程式碼,SAST 掃得出來嗎?

掃得出來,而且特別需要掃。 AI 生成的程式碼經常包含安全問題——字串拼接 SQL、缺少輸入驗證、硬編碼測試用的金鑰等。SAST 不管程式碼是人寫的還是 AI 寫的,一律用相同的規則掃描。

建議:把 SAST 當成 AI 生成程式碼的「安全審查員」。AI 幫你寫完程式碼後,先用 Semgrep 掃一遍再 commit——這能幫你抓到大部分 AI 常犯的安全錯誤。


十、結語:讓工具成為你的安全夥伴

回到蓋房子的比喻。

沒有人會說:「我蓋了二十年的房子,用眼睛看就夠了,不需要什麼驗屋師。」因為不管你多有經驗,總有看漏的地方。而且,當你同時管理好幾棟房子的施工進度時,你更需要自動化的檢查工具幫你盯住每一個環節。

SAST 也是一樣。它不是要取代你的專業判斷,而是幫你處理那些重複性高、模式化、容易被疏忽的安全檢查。讓工具做工具擅長的事,你才能把寶貴的注意力放在更重要的地方——像是商業邏輯的安全設計、架構層級的防禦策略、以及培養團隊的安全文化。

安全工具不是給開發者加壓力的枷鎖,而是讓你寫程式時更安心的夥伴。
當 IDE 裡的紅色波浪線從「煩人的干擾」變成「可靠的提醒」,你就知道——SAST 已經融入你的開發日常了。

下一篇,我們將進入安全驗證的第二個重要主題——DAST 動態測試實戰:用 OWASP ZAP 掃描網站。學完 SAST 看程式碼內部,接下來要學怎麼從外部攻擊自己的系統,找出只有在運行時才會出現的漏洞。


延伸閱讀

[安全程式設計] 003 密碼雜湊與機敏資料處理實戰指南:bcrypt vs Argon2、AES-256 加密與金鑰管理

「密碼存明碼,就像把金庫密碼寫在便利貼上貼在螢幕旁邊——
你不是沒鎖門,你是把鑰匙插在門上。」
— SSDLC by 飛飛


一、加密與雜湊是什麼?為什麼搞混了會出大事?

在 SSDLC 的「蓋房子」旅程中,我們已經走過了安全需求定義(確認房子要防什麼)、安全設計(畫好建築藍圖)、安全編碼基礎(學會用防火建材和安全電線)、OWASP Top 10 防禦(認識十大常見工安事故)。

現在,我們要處理蓋房子時最珍貴的東西——保險箱裡的財物

在軟體系統中,「保險箱裡的財物」就是密碼、信用卡號、個人身分證字號、API 金鑰這些機敏資料(Sensitive Data)。這些資料如果被偷走或外洩,後果不堪設想:使用者帳號被盜、信用卡被盜刷、公司面臨個資法罰款(最高 NT$1,500 萬)。

而保護這些財物的兩個核心工具,就是加密(Encryption)雜湊(Hashing)

很多開發者會搞混這兩個概念,甚至互相混用——這就像把保險箱當碎紙機用,或拿碎紙機當保險箱。先用一個生活比喻釐清:

概念 英文 生活比喻 核心特性 適用場景
加密 Encryption 把信件放進上鎖的保險箱,有鑰匙的人可以打開 可逆——加密後可以解密還原 傳輸中的資料、儲存需要讀取的機敏資料
雜湊 Hashing 把文件丟進碎紙機,變成固定大小的碎片 不可逆——無法從碎片還原文件 密碼儲存、資料完整性驗證

一句話記住:需要還原的用加密,不需要還原的用雜湊。

密碼為什麼要用雜湊而不是加密?因為你永遠不需要知道使用者的原始密碼。登入時,你只需要把使用者輸入的密碼做雜湊,跟資料庫裡存的雜湊值比對就好。如果你用加密存密碼,代表你的系統「有能力解密看到所有人的密碼」——萬一這把鑰匙被偷了,全部密碼一次外洩。

飛飛觀點:
我見過太多台灣新創把密碼用 AES 加密存在資料庫裡,覺得「有加密就安全了」。問題是:加密的金鑰放哪裡?如果金鑰跟密碼存在同一台伺服器上,那攻擊者只要入侵一次就全拿走了。密碼用雜湊,就是讓攻擊者就算拿到資料庫,也拿不到原始密碼。


二、密碼雜湊:bcrypt vs Argon2——別再用 MD5 了

2.1 為什麼 MD5 和 SHA-256 不能用來雜湊密碼?

很多教科書的範例還在用 <code>md5(password)</code> 或 <code>sha256(password)</code>,但這些通用雜湊函數根本不是設計來保護密碼的。原因有三:

問題 說明 後果
太快了 MD5 每秒可以計算數十億次,SHA-256 也差不多 攻擊者用 GPU 暴力破解,8 碼密碼幾小時就破完
沒有鹽(Salt) 同樣的密碼永遠產生同樣的雜湊 攻擊者用彩虹表(Rainbow Table)秒查
不能調整難度 運算量固定,無法隨硬體進步而增加 十年前安全的設定,現在可能已經不夠

什麼是彩虹表?簡單說,就是有人事先把常見密碼(如 <code>123456</code>、<code>password</code>、<code>qwerty123</code>)的 MD5/SHA-256 雜湊值全部算好,建成一個超大的對照表。攻擊者拿到你資料庫裡的雜湊值,直接查表就知道原始密碼。中國甚至有商業網站(如 CMD5)專門提供這種反查服務。

密碼專用雜湊函數(如 bcrypt、Argon2)專門解決這三個問題:加鹽、故意設計得很慢、可以調整難度。

2.2 bcrypt:老牌可靠的密碼守衛

bcrypt 誕生於 1999 年,是目前使用最廣泛的密碼雜湊演算法。它有三個關鍵特性:

  • 自動加鹽:每次雜湊都會產生隨機鹽值,同樣的密碼會得到不同的雜湊值
  • 可調整工作因子(Cost Factor):數字每加 1,計算時間翻倍
  • 經過 25 年以上的實戰考驗
// Node.js — 使用 bcrypt 雜湊密碼
const bcrypt = require('bcrypt');

// ✅ 註冊時:雜湊密碼
async function hashPassword(plainPassword) {
  const saltRounds = 12; // 工作因子:2^12 = 4096 次迭代
  // saltRounds 每加 1,計算時間約翻倍
  // 10 ≈ 100ms, 12 ≈ 300ms, 14 ≈ 1s(在現代硬體上)
  return await bcrypt.hash(plainPassword, saltRounds);
}

// ✅ 登入時:驗證密碼
async function verifyPassword(plainPassword, hashedPassword) {
  // bcrypt.compare 會自動從雜湊值中提取鹽值來比對
  return await bcrypt.compare(plainPassword, hashedPassword);
}

// 使用範例
const hashed = await hashPassword('MyP@ssw0rd!2024');
// 結果類似:$2b$12$LJ3m5x2Y8v4Z... (每次都不同,因為鹽值不同)

const isValid = await verifyPassword('MyP@ssw0rd!2024', hashed);
// true

2.3 Argon2:新一代密碼雜湊冠軍

Argon2 是 2015 年密碼雜湊競賽(Password Hashing Competition, PHC)的冠軍,被認為是目前最安全的密碼雜湊演算法。它比 bcrypt 多了一個超能力:抵抗 GPU 暴力破解

bcrypt 主要消耗 CPU 運算時間,但現代 GPU(如 NVIDIA RTX 4090)有數千個核心可以平行計算,能大幅加速暴力破解。Argon2 不只消耗 CPU,還會消耗大量記憶體,而 GPU 的記憶體相對有限且昂貴,這讓 GPU 暴力破解變得極不划算。

Argon2 有三種變體:

變體 特性 建議場景
Argon2d 抗 GPU 攻擊,但可能受 side-channel 攻擊影響 加密貨幣挖礦
Argon2i 抗 side-channel 攻擊 密碼雜湊(較保守)
Argon2id 結合 d 和 i 的優點 密碼雜湊的首選(OWASP 推薦)
// Node.js — 使用 Argon2id 雜湊密碼(推薦)
const argon2 = require('argon2');

// ✅ 註冊時:雜湊密碼
async function hashPassword(plainPassword) {
  return await argon2.hash(plainPassword, {
    type: argon2.argon2id,  // 使用 Argon2id 變體
    memoryCost: 65536,      // 64 MB 記憶體(讓 GPU 暴力破解變得昂貴)
    timeCost: 3,            // 迭代次數
    parallelism: 4          // 平行度(使用 4 個執行緒)
  });
}

// ✅ 登入時:驗證密碼
async function verifyPassword(plainPassword, hashedPassword) {
  return await argon2.verify(hashedPassword, plainPassword);
}

// ✅ 進階:根據伺服器效能動態調整參數
async function hashPasswordWithTuning(plainPassword) {
  const startTime = Date.now();
  const hashed = await argon2.hash(plainPassword, {
    type: argon2.argon2id,
    memoryCost: 65536,
    timeCost: 3,
    parallelism: 4
  });
  const elapsed = Date.now() - startTime;

  // 目標:雜湊一次花費 200ms ~ 500ms
  // 太快 = 攻擊者也能快速暴力破解
  // 太慢 = 使用者登入體驗差
  console.log(<code>雜湊耗時:${elapsed}ms</code>);

  return hashed;
}

2.4 bcrypt vs Argon2:到底該選哪個?

比較項目 bcrypt Argon2id
問世年份 1999 年 2015 年
抗 GPU 攻擊 中等(主要消耗 CPU) (消耗 CPU + 大量記憶體)
成熟度 25 年以上實戰,非常成熟 較新,但已獲廣泛認可
OWASP 推薦 ✅ 推薦(備選) 首選推薦
生態系支援 幾乎所有語言都有穩定套件 主流語言都有,但部分套件需要原生編譯
密碼長度限制 最多 72 bytes(超過會被截斷) 無實質限制
參數調整彈性 只能調工作因子 可調記憶體、時間、平行度
建議使用場景 既有系統、部署環境受限 新專案首選

飛飛觀點:
如果你在做新專案,選 Argon2id。如果你的系統已經在用 bcrypt 而且 cost factor 夠高(至少 12),不用急著遷移——bcrypt 依然是安全的。最重要的是:不管你選哪個,都比 MD5、SHA-256、甚至 SHA-512 好一萬倍。

2.5 密碼雜湊的 OWASP 建議參數

// OWASP 2024 推薦的最低參數設定

// Argon2id(首選)
const argon2Config = {
  type: argon2.argon2id,
  memoryCost: 19456,    // OWASP 最低建議:19 MB
  timeCost: 2,          // OWASP 最低建議:2 次迭代
  parallelism: 1        // OWASP 最低建議:1 執行緒
};
// 如果伺服器資源允許,建議提高到:memoryCost: 65536, timeCost: 3, parallelism: 4

// bcrypt(備選)
const bcryptConfig = {
  saltRounds: 12        // OWASP 最低建議:10,建議用 12 以上
};

三、對稱加密 vs 非對稱加密:什麼時候該用哪一把鎖?

密碼用雜湊解決了,但有些資料你需要加密後還能解密還原——比如使用者的信用卡號、身分證字號、醫療記錄。這時候就要用「加密」了。

加密分成兩大類:對稱加密非對稱加密。用一個生活比喻來區分:

3.1 對稱加密:同一把鑰匙開同一把鎖

比喻:你跟室友共用一把門鑰匙。你鎖門,他也能開門,因為你們用的是同一把鑰匙。

對稱加密的「加密」和「解密」用的是同一把金鑰。速度快、效率高,適合加密大量資料。

演算法 安全等級 金鑰長度 狀態
DES ❌ 已不安全 56 bit 已淘汰,別用
3DES ⚠️ 逐步淘汰 168 bit NIST 2023 年後禁用
AES-128 ✅ 安全 128 bit 目前標準
AES-256 ✅ 非常安全 256 bit 推薦使用
// Node.js — 使用 AES-256-GCM 加密(推薦)
const crypto = require('crypto');

// ✅ 加密函數
function encrypt(plainText, secretKey) {
  // GCM 模式提供加密 + 完整性驗證(比 CBC 更安全)
  const iv = crypto.randomBytes(12);  // GCM 建議 12 bytes IV
  const cipher = crypto.createCipheriv('aes-256-gcm', secretKey, iv);

  let encrypted = cipher.update(plainText, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  const authTag = cipher.getAuthTag(); // 認證標籤,防止密文被竄改

  // 將 IV + 認證標籤 + 密文一起存儲
  return {
    iv: iv.toString('hex'),
    authTag: authTag.toString('hex'),
    encryptedData: encrypted
  };
}

// ✅ 解密函數
function decrypt(encryptedObj, secretKey) {
  const decipher = crypto.createDecipheriv(
    'aes-256-gcm',
    secretKey,
    Buffer.from(encryptedObj.iv, 'hex')
  );

  decipher.setAuthTag(Buffer.from(encryptedObj.authTag, 'hex'));

  let decrypted = decipher.update(encryptedObj.encryptedData, 'hex', 'utf8');
  decrypted += decipher.final('utf8');

  return decrypted;
}

// 使用範例
const key = crypto.randomBytes(32); // 256 bit 金鑰
const encrypted = encrypt('4311-9522-0000-1234', key);
const decrypted = decrypt(encrypted, key);
// decrypted === '4311-9522-0000-1234'

為什麼用 GCM 而不是 CBC?

模式 加密 完整性驗證 說明
AES-CBC 只加密,不驗證。攻擊者可以偷偷修改密文(Padding Oracle Attack)
AES-GCM 加密 + 驗證。密文被竄改會直接解密失敗

3.2 非對稱加密:一把鑰匙鎖門,另一把鑰匙開門

比喻:你家門口有一個投信口(公鑰),任何人都可以把信塞進去。但只有你拿著門鑰匙(私鑰)才能打開信箱拿信。

非對稱加密使用一對金鑰:公鑰(Public Key)和私鑰(Private Key)。公鑰加密的東西只有私鑰能解密,反之亦然。

演算法 金鑰長度 速度 適用場景
RSA 2048+ bit 較慢 數位簽章、金鑰交換(傳統方案)
ECDSA 256 bit 數位簽章(現代首選)
ECDH 256 bit 金鑰交換
Ed25519 256 bit 很快 數位簽章(最新推薦)
// Node.js — 使用 RSA 非對稱加密
const crypto = require('crypto');

// ✅ Step 1:產生金鑰對
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});

// ✅ Step 2:用公鑰加密(任何人都可以加密)
function encryptWithPublicKey(plainText, pubKey) {
  const encrypted = crypto.publicEncrypt(
    {
      key: pubKey,
      padding: crypto.constants.RSA_OAEP_PADDING,
      oaepHash: 'sha256'
    },
    Buffer.from(plainText)
  );
  return encrypted.toString('base64');
}

// ✅ Step 3:用私鑰解密(只有持有私鑰的人能解密)
function decryptWithPrivateKey(encryptedBase64, privKey) {
  const decrypted = crypto.privateDecrypt(
    {
      key: privKey,
      padding: crypto.constants.RSA_OAEP_PADDING,
      oaepHash: 'sha256'
    },
    Buffer.from(encryptedBase64, 'base64')
  );
  return decrypted.toString('utf8');
}

3.3 什麼時候用對稱、什麼時候用非對稱?

場景 用什麼 原因
加密資料庫中的信用卡號 對稱加密(AES-256-GCM) 加解密都在自己的伺服器,速度快
加密 API 傳輸中的資料 非對稱 + 對稱(HTTPS/TLS) 先用非對稱交換對稱金鑰,再用對稱加密資料
JWT Token 簽章 非對稱(RS256 / ES256) 多個服務需要驗證 Token,但只有一個服務能簽發
加密設定檔中的密碼 對稱加密(AES-256) 單機使用,速度快
驗證軟體更新的真實性 非對稱(數位簽章) 發布者用私鑰簽章,使用者用公鑰驗證

飛飛觀點:
在實務上,很少「只用對稱」或「只用非對稱」。HTTPS(TLS)就是一個完美的混合案例:先用非對稱加密安全地交換一組「對話金鑰」(Session Key),之後的通訊就用這把對稱金鑰來加密。這叫做混合加密(Hybrid Encryption)——用非對稱解決「怎麼安全傳遞金鑰」的問題,用對稱解決「怎麼高效加密大量資料」的問題。


四、金鑰管理:最容易被忽略的致命問題

你可以用最強的 AES-256 加密、最安全的 Argon2id 雜湊,但如果金鑰管理沒做好,一切都是白搭。

這就像你買了一把世界上最堅固的鎖,但把鑰匙藏在門口的花盆底下——小偷第一個就翻那裡。

4.1 金鑰管理最常犯的五大錯誤

錯誤 有多常見 後果
金鑰硬寫在程式碼裡 非常常見 推上 GitHub = 全世界都看得到
金鑰跟資料存在同一台伺服器 常見 伺服器被入侵 = 金鑰 + 資料一起外洩
所有環境用同一組金鑰 常見 開發環境洩漏 = 正式環境也完蛋
金鑰永遠不換(No Rotation) 非常常見 金鑰洩漏卻不知道,損害持續擴大
沒有金鑰備份與恢復計畫 常見 金鑰遺失 = 資料永遠無法解密
// ❌ 最常見的致命錯誤:金鑰硬寫在程式碼裡
const SECRET_KEY = 'my-super-secret-key-12345';  // 推上 GitHub 就完蛋
const DB_PASSWORD = 'admin123';                    // 更不用說這種
const API_KEY = 'sk-abc123def456';                 // 第三方 API 金鑰也是

// ❌ 稍微好一點但還是不夠:寫在設定檔裡
// config.json(也被推上了 GitHub)
// { "secretKey": "my-super-secret-key" }

// ✅ 正確做法:從環境變數讀取
const SECRET_KEY = process.env.JWT_SECRET;
const DB_PASSWORD = process.env.DB_PASSWORD;
const API_KEY = process.env.STRIPE_API_KEY;

// ✅ 加上驗證:確保金鑰存在且符合最低要求
function validateEnvSecrets() {
  const required = ['JWT_SECRET', 'DB_PASSWORD', 'ENCRYPTION_KEY'];

  for (const key of required) {
    if (!process.env[key]) {
      throw new Error(<code>環境變數 ${key} 未設定,拒絕啟動</code>);
    }
    if (process.env[key].length < 32) {
      throw new Error(<code>環境變數 ${key} 長度不足 32 字元</code>);
    }
  }
}

// 在應用程式啟動時立即檢查
validateEnvSecrets();

4.2 金鑰管理的四個層級

從「堪用」到「企業級」,金鑰管理可以分成四個層級。你可以根據專案的規模和敏感程度,選擇適合的層級:

層級 做法 適合場景 安全程度
Level 1 環境變數 + <code>.env</code> 檔(不進版控) Side project、個人專案 ⭐⭐
Level 2 CI/CD 的 Secrets 管理(GitHub Secrets、GitLab CI Variables) 小型團隊、新創 ⭐⭐⭐
Level 3 專用的 Secrets Manager(AWS Secrets Manager、HashiCorp Vault) 中型以上企業 ⭐⭐⭐⭐
Level 4 HSM 硬體安全模組(Hardware Security Module) 金融業、政府機關、醫療體系 ⭐⭐⭐⭐⭐

Level 1:環境變數(最低要求)

# .env 檔案(加入 .gitignore,絕對不能進版控)
JWT_SECRET=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
ENCRYPTION_KEY=q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2
DB_PASSWORD=your-strong-database-password-here
# .gitignore — 這幾行是必須的
.env
.env.local
.env.production
*.key
*.pem

Level 3:AWS Secrets Manager 範例

// Node.js — 使用 AWS Secrets Manager
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');

const client = new SecretsManagerClient({ region: 'ap-northeast-1' }); // 東京區域

async function getSecret(secretName) {
  const command = new GetSecretValueCommand({ SecretId: secretName });
  const response = await client.send(command);
  return JSON.parse(response.SecretString);
}

// 應用程式啟動時載入所有 Secrets
async function loadSecrets() {
  const secrets = await getSecret('my-app/production');
  // secrets = { JWT_SECRET: '...', DB_PASSWORD: '...', ENCRYPTION_KEY: '...' }
  return secrets;
}

4.3 金鑰輪換(Key Rotation):定期換鎖

金鑰跟密碼一樣,不是設了就永遠不動。定期更換金鑰(Key Rotation)是金鑰管理的核心實踐。

為什麼要輪換?因為你不一定知道金鑰什麼時候已經洩漏了。定期更換,可以限制洩漏的影響範圍。

// Node.js — 金鑰輪換策略:同時支援新舊金鑰
const ENCRYPTION_KEYS = {
  // 金鑰版本管理:新資料用最新版加密,解密時根據版本號選擇對應金鑰
  'v3': process.env.ENCRYPTION_KEY_V3,  // 目前使用(加密用)
  'v2': process.env.ENCRYPTION_KEY_V2,  // 前一版(解密用,過渡期保留)
  // 'v1' 已廢棄,不再保留
};

const CURRENT_KEY_VERSION = 'v3';

// ✅ 加密時:永遠使用最新版金鑰,並記錄版本號
function encryptWithVersion(plainText) {
  const key = Buffer.from(ENCRYPTION_KEYS[CURRENT_KEY_VERSION], 'hex');
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);

  let encrypted = cipher.update(plainText, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  return {
    version: CURRENT_KEY_VERSION,  // 記錄用了哪個版本的金鑰
    iv: iv.toString('hex'),
    authTag: cipher.getAuthTag().toString('hex'),
    data: encrypted
  };
}

// ✅ 解密時:根據版本號找到對應的金鑰
function decryptWithVersion(encryptedObj) {
  const key = Buffer.from(ENCRYPTION_KEYS[encryptedObj.version], 'hex');
  if (!key) {
    throw new Error(<code>金鑰版本 ${encryptedObj.version} 已廢棄,無法解密</code>);
  }

  const decipher = crypto.createDecipheriv(
    'aes-256-gcm', key, Buffer.from(encryptedObj.iv, 'hex')
  );
  decipher.setAuthTag(Buffer.from(encryptedObj.authTag, 'hex'));

  let decrypted = decipher.update(encryptedObj.data, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return decrypted;
}

// ✅ 背景任務:逐步將舊版金鑰加密的資料重新加密
async function reEncryptOldRecords() {
  const oldRecords = await db.query(
    "SELECT id, encrypted_data FROM sensitive_data WHERE encrypted_data->>'version' != $1",
    [CURRENT_KEY_VERSION]
  );

  for (const record of oldRecords.rows) {
    const decrypted = decryptWithVersion(record.encrypted_data);
    const reEncrypted = encryptWithVersion(decrypted);
    await db.query(
      'UPDATE sensitive_data SET encrypted_data = $1 WHERE id = $2',
      [JSON.stringify(reEncrypted), record.id]
    );
  }
}

飛飛觀點:
金鑰輪換最怕的就是「換了新鑰匙,舊鎖打不開了」。所以一定要做版本管理——加密時記錄用了哪個版本的金鑰,解密時根據版本號去找對應的金鑰。過渡期內新舊金鑰並存,等所有舊資料都重新加密完了,再廢棄舊金鑰。


五、實戰案例:台灣電商會員系統的機敏資料處理

讓我們用一個完整的案例,走過從頭到尾的機敏資料處理流程。

場景描述

你的團隊要為一間台灣電商平台處理以下機敏資料:

資料類型 範例 處理方式 原因
密碼 <code>MyP@ss123</code> Argon2id 雜湊 不需要還原,只要比對
信用卡號 <code>4311-9522-0000-1234</code> AES-256-GCM 加密 需要解密用於扣款
身分證字號 <code>A123456789</code> AES-256-GCM 加密 法規要求可查詢
Email <code>user@example.com</code> 明碼存儲 + 存取控制 需要經常讀取,用權限控制保護
手機號碼 <code>0912-345-678</code> AES-256-GCM 加密 或遮蔽 依業務需求決定
JWT Secret <code>a1b2c3…</code> 環境變數 / Secrets Manager 金鑰,不進資料庫

完整程式碼範例

// /src/services/crypto.service.js
// 台灣電商平台:機敏資料處理服務

const argon2 = require('argon2');
const crypto = require('crypto');

class CryptoService {
  constructor() {
    // 從環境變數載入金鑰(啟動時驗證)
    this.encryptionKey = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
    if (this.encryptionKey.length !== 32) {
      throw new Error('ENCRYPTION_KEY 必須是 64 個 hex 字元(32 bytes)');
    }
  }

  // ===== 密碼處理(雜湊,不可逆)=====

  async hashPassword(plainPassword) {
    return await argon2.hash(plainPassword, {
      type: argon2.argon2id,
      memoryCost: 65536,
      timeCost: 3,
      parallelism: 4
    });
  }

  async verifyPassword(plainPassword, hashedPassword) {
    try {
      return await argon2.verify(hashedPassword, plainPassword);
    } catch {
      return false; // 格式錯誤也回傳 false,不洩漏錯誤資訊
    }
  }

  // ===== 機敏資料加密(可逆)=====

  encrypt(plainText) {
    const iv = crypto.randomBytes(12);
    const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);

    let encrypted = cipher.update(plainText, 'utf8', 'hex');
    encrypted += cipher.final('hex');

    // 將 IV + AuthTag + 密文合併為一個字串,方便存儲
    return [
      iv.toString('hex'),
      cipher.getAuthTag().toString('hex'),
      encrypted
    ].join(':');
  }

  decrypt(encryptedString) {
    const [ivHex, authTagHex, encryptedData] = encryptedString.split(':');

    const decipher = crypto.createDecipheriv(
      'aes-256-gcm',
      this.encryptionKey,
      Buffer.from(ivHex, 'hex')
    );
    decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));

    let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
    decrypted += decipher.final('utf8');
    return decrypted;
  }

  // ===== 資料遮蔽(顯示用)=====

  maskCreditCard(cardNumber) {
    // 4311-9522-0000-1234 → **** **** **** 1234
    const clean = cardNumber.replace(/[-\s]/g, '');
    return <code class="kb-btn">**** **** **** ${clean.slice(-4)}</code>;
  }

  maskPhone(phone) {
    // 0912-345-678 → 09XX-XXX-678
    return phone.replace(/(\d{2})\d{2}-\d{3}-(\d{3})/, '$1XX-XXX-$2');
  }

  maskIdNumber(idNumber) {
    // A123456789 → A12*****89
    return idNumber.slice(0, 3) + '*****' + idNumber.slice(-2);
  }
}

module.exports = new CryptoService();
// /src/routes/user.routes.js
// 註冊與登入流程

const cryptoService = require('../services/crypto.service');

// ✅ 註冊:密碼雜湊 + 機敏資料加密
app.post('/api/register', async (req, res) => {
  const { email, password, phone, idNumber } = req.body;

  // 密碼 → Argon2id 雜湊(不可逆)
  const passwordHash = await cryptoService.hashPassword(password);

  // 手機 → AES-256-GCM 加密(可逆,需要發簡訊驗證)
  const encryptedPhone = cryptoService.encrypt(phone);

  // 身分證 → AES-256-GCM 加密(可逆,法規要求可查詢)
  const encryptedId = cryptoService.encrypt(idNumber);

  await db.query(
    `INSERT INTO users (email, password_hash, phone_encrypted, id_number_encrypted) 
     VALUES ($1, $2, $3, $4)`,
    [email, passwordHash, encryptedPhone, encryptedId]
  );

  res.status(201).json({ message: '註冊成功' });
});

// ✅ 登入:密碼驗證 + 防止 Timing Attack
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;

  const user = await db.query(
    'SELECT id, password_hash, login_attempts, locked_until FROM users WHERE email = $1',
    [email]
  );

  // 不管帳號存不存在,都花相同時間做雜湊比對(防 Timing Attack)
  if (!user.rows[0]) {
    await cryptoService.hashPassword('dummy-password');
    return res.status(401).json({ error: '帳號或密碼錯誤' });
  }

  const userData = user.rows[0];

  // 檢查是否被鎖定
  if (userData.locked_until && new Date(userData.locked_until) > new Date()) {
    return res.status(423).json({ error: '帳號暫時鎖定,請稍後再試' });
  }

  const isValid = await cryptoService.verifyPassword(password, userData.password_hash);

  if (!isValid) {
    // 登入失敗:增加嘗試次數
    const newAttempts = (userData.login_attempts || 0) + 1;
    const lockUntil = newAttempts >= 5 
      ? new Date(Date.now() + 15 * 60 * 1000)  // 5 次失敗鎖 15 分鐘
      : null;

    await db.query(
      'UPDATE users SET login_attempts = $1, locked_until = $2 WHERE id = $3',
      [newAttempts, lockUntil, userData.id]
    );

    return res.status(401).json({ error: '帳號或密碼錯誤' });
  }

  // 登入成功:重設嘗試次數
  await db.query(
    'UPDATE users SET login_attempts = 0, locked_until = NULL WHERE id = $1',
    [userData.id]
  );

  // ... 簽發 JWT Token
});

// ✅ 查看個人資料:解密 + 遮蔽
app.get('/api/profile', authenticate, async (req, res) => {
  const user = await db.query(
    'SELECT email, phone_encrypted, id_number_encrypted FROM users WHERE id = $1',
    [req.user.id]
  );

  const userData = user.rows[0];

  // 解密後遮蔽顯示(前端只看到遮蔽版本)
  const phone = cryptoService.decrypt(userData.phone_encrypted);
  const idNumber = cryptoService.decrypt(userData.id_number_encrypted);

  res.json({
    email: userData.email,
    phone: cryptoService.maskPhone(phone),         // 09XX-XXX-678
    idNumber: cryptoService.maskIdNumber(idNumber)  // A12*****89
  });
});

六、密碼與機敏資料處理 Checklist

以下是一份可以直接用於專案的檢核清單:

## 密碼安全
- [ ] 密碼使用 Argon2id(首選)或 bcrypt(備選)雜湊
- [ ] 絕對沒有使用 MD5、SHA-1、SHA-256 雜湊密碼
- [ ] bcrypt 的 cost factor ≥ 12
- [ ] Argon2id 的 memoryCost ≥ 19456(OWASP 最低要求)
- [ ] 雜湊一次的耗時在 200ms ~ 500ms 之間
- [ ] 登入錯誤訊息統一為「帳號或密碼錯誤」
- [ ] 有防止 Timing Attack 的措施
- [ ] 登入失敗 5 次鎖定帳號 15 分鐘

## 加密機制
- [ ] 對稱加密使用 AES-256-GCM(不是 CBC)
- [ ] 每次加密使用隨機的 IV(不可重複使用)
- [ ] 加密演算法沒有使用 DES、3DES、RC4
- [ ] 傳輸中的資料全程使用 TLS 1.2 以上
- [ ] 信用卡號、身分證等機敏資料已加密存儲

## 金鑰管理
- [ ] 金鑰沒有硬寫在程式碼裡
- [ ] 金鑰沒有存在版本控制(Git)中
- [ ] .env 檔案已加入 .gitignore
- [ ] 開發 / 測試 / 正式環境使用不同的金鑰
- [ ] 有金鑰輪換計畫(至少每年更換一次)
- [ ] 金鑰輪換時有版本管理(新舊金鑰並存)
- [ ] 有金鑰備份與恢復計畫

## 資料遮蔽
- [ ] 信用卡號顯示為 **** **** **** 末四碼
- [ ] 手機號碼部分遮蔽
- [ ] 身分證字號部分遮蔽
- [ ] 日誌中不記錄任何機敏資料的明碼
- [ ] API Response 中不回傳不必要的機敏欄位

七、團隊落地建議:讓加密不再是一個人的事

建議一:建立團隊的機敏資料分類表

在專案一開始,就列出所有會經手的機敏資料,並決定每種資料的處理方式。這份表格應該放在專案文件中,所有開發者都能看到。

| 資料類型 | 分類等級 | 存儲方式 | 傳輸方式 | 顯示方式 | 負責人 |
|---------|---------|---------|---------|---------|-------|
| 密碼 | 極高 | Argon2id 雜湊 | TLS | 不顯示 | 後端 |
| 信用卡號 | 極高 | AES-256 加密 | TLS | 遮蔽末四碼 | 後端 |
| 身分證 | 高 | AES-256 加密 | TLS | 部分遮蔽 | 後端 |
| 手機號碼 | 中 | AES-256 加密 | TLS | 部分遮蔽 | 後端 |
| Email | 中 | 明碼 + 存取控制 | TLS | 完整顯示 | 後端 |
| 姓名 | 低 | 明碼 + 存取控制 | TLS | 完整顯示 | 前端 |

建議二:封裝共用的加密服務

不要讓每個開發者自己寫加密邏輯。把加密、解密、雜湊都封裝成共用的服務(就像本文的 <code>CryptoService</code>),確保全團隊用同一套標準。

建議三:在 Code Review 加入加密檢查

在 Code Review 時,加入以下安全問題:

  • 這段程式碼有處理機敏資料嗎?用了什麼加密方式?
  • 金鑰從哪裡來?有沒有硬寫在程式碼裡?
  • 日誌中有沒有不小心記錄了機敏資料?
  • API Response 有沒有回傳不必要的機敏欄位?

建議四:用 Git Hook 防止金鑰外洩

# 安裝 git-secrets(由 AWS 開發的工具)
# brew install git-secrets  (macOS)
# 或手動安裝:https://github.com/awslabs/git-secrets

# 初始化
git secrets --install
git secrets --register-aws  # 偵測 AWS 金鑰

# 自訂規則:偵測常見的金鑰格式
git secrets --add 'password\s*=\s*.+'
git secrets --add 'secret\s*=\s*.+'
git secrets --add 'PRIVATE.KEY'

# 之後每次 git commit 都會自動檢查,發現金鑰就阻止提交

八、常見問題 FAQ

Q1:我的專案很小,真的需要這麼複雜的加密機制嗎?

規模小不代表可以偷懶。 只要你的系統有處理密碼和個人資料,就需要做好基本的保護。好消息是,基礎的保護並不複雜:密碼用 bcrypt/Argon2 雜湊(一個函式呼叫的事)、金鑰不要硬寫在程式碼裡(用 <code>.env</code> 就好)、傳輸用 HTTPS。這三件事花不了你一天的時間,但能幫你省下未來幾個月的災難處理。

Q2:我的系統已經用 MD5 雜湊密碼了,怎麼遷移到 Argon2?

這是很多既有系統會遇到的問題。你不需要強制所有使用者重設密碼。一個常見的漸進式遷移策略:在使用者下次登入成功時(表示他輸入了正確的密碼),趁這個機會用 Argon2 重新雜湊並更新資料庫。同時在資料庫加一個欄位記錄雜湊演算法版本,登入驗證時根據版本選擇不同的比對方式。

// 漸進式遷移範例
async function loginWithMigration(email, password) {
  const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);

  if (user.hash_version === 'md5') {
    // 舊格式:用 MD5 驗證
    const md5Hash = crypto.createHash('md5').update(password).digest('hex');
    if (md5Hash !== user.password_hash) return false;

    // 驗證成功!趁機升級到 Argon2
    const newHash = await argon2.hash(password, { type: argon2.argon2id });
    await db.query(
      'UPDATE users SET password_hash = $1, hash_version = $2 WHERE id = $3',
      [newHash, 'argon2id', user.id]
    );
  } else {
    // 新格式:用 Argon2 驗證
    return await argon2.verify(user.password_hash, password);
  }
  return true;
}

Q3:AES 加密的金鑰應該是多少 bytes?怎麼產生?

AES-256 需要 32 bytes(256 bits)的金鑰。用密碼學安全的隨機數產生器來產生,絕對不要自己亂打一串字。

// ✅ 正確:用 crypto 產生隨機金鑰
const key = crypto.randomBytes(32).toString('hex');
console.log(key); // 64 個 hex 字元,例如:a1b2c3d4...

// ❌ 錯誤:用自己想的字串
const key = 'my-secret-key';  // 太短、可預測、不夠隨機

Q4:JWT Token 裡可以放密碼或信用卡號嗎?

絕對不行。 JWT 預設只做簽章(signature),不做加密。也就是說,任何人拿到你的 JWT,都可以用 Base64 解碼看到裡面的內容。JWT 裡應該只放使用者 ID 和角色等「就算被看到也不會造成直接危害」的資訊。密碼、信用卡號、身分證等機敏資料,永遠不應該出現在 JWT 裡。


九、結語:加密是手藝,不是魔法

回到蓋房子的比喻。

保險箱再堅固,鑰匙管理不好也白搭。加密演算法再強大,金鑰硬寫在程式碼裡也沒用。密碼雜湊再安全,不加鹽就形同虛設。

密碼與機敏資料處理的核心不是「用了什麼演算法」,而是「有沒有在每一個環節都做對」。從資料的產生、傳輸、存儲、到顯示,每一步都需要正確的保護措施。就像蓋房子的保險箱,不只是買一個好保險箱就夠了——你還要把它固定在牆裡、保管好鑰匙、定期更換密碼、只讓該看的人看。

安全不是一次性的動作,而是持續的紀律。

飛飛觀點:
記住,完美的加密機制不存在,但「夠好的加密」加上「正確的管理」,可以讓攻擊者的成本高到不值得攻擊你。安全不是恐懼,而是創造的基礎——當你知道使用者的資料被好好保護,你才能安心地創造更好的產品。


十、延伸閱讀

官方資源

工具推薦

工具 用途 說明
argon2 Node.js Argon2 雜湊 OWASP 推薦首選
bcrypt Node.js bcrypt 雜湊 老牌穩定
git-secrets 防止金鑰提交到 Git AWS 開發的工具
HashiCorp Vault 金鑰管理 開源 Secrets Manager
AWS Secrets Manager 雲端金鑰管理 AWS 原生服務

[安全程式設計] 002 OWASP Top 10 2025 防禦實戰指南:十大風險逐一擊破,從觀念到程式碼的完整攻防手冊

「知道鎖壞了不夠,你得知道怎麼換一把好鎖。
OWASP Top 10 不只是一張清單,它是你防禦的施工藍圖。」
— SSDLC by 飛飛


一、OWASP Top 10 是什麼?為什麼每個開發者都該懂?

在 SSDLC 蓋房子的旅程中,我們走過了安全需求定義(確認要防什麼)、安全設計(畫好藍圖)、安全編碼基礎(學會用防火建材)。現在,是時候面對真正的敵人了。

OWASP Top 10 就像建築界的「十大常見工安事故報告」。每隔幾年,OWASP(Open Worldwide Application Security Project)會根據全球數十萬個應用程式的真實漏洞數據,整理出最危險的十大安全風險。2025 年版本是第八版,基於超過 175,000 筆 CVE 紀錄和 589 個 CWE 分析而來。

你可能會問:「我已經學了輸入驗證和輸出編碼,為什麼還需要這個?」

輸入驗證和輸出編碼是「基本功」——就像學會了怎麼接電線和鋪防火磚。但真正蓋房子的時候,你會遇到各種不同類型的風險:有人從窗戶爬進來(權限控制失效)、有人複製了你的鑰匙(認證失敗)、有人在你的建材裡摻了劣質品(軟體供應鏈攻擊)。OWASP Top 10 就是告訴你:這十種風險最常發生,你得優先防好。

飛飛觀點:
我常跟團隊說,OWASP Top 10 不是「考試範圍」,而是「體檢報告」。它告訴你全世界的開發者最常在哪裡跌倒。如果你能把這十項都防好,你的系統就已經比八成的網站更安全了。


二、2025 年版有什麼不同?從 2021 到 2025 的演進

在進入逐條解析之前,先快速看看 2025 年版的重大變化:

2021 年版 2025 年版 變化說明
A01: Broken Access Control A01: Broken Access Control 維持第一,SSRF 併入此類
A05: Security Misconfiguration A02: Security Misconfiguration 從第五躍升至第二
A06: Vulnerable and Outdated Components A03: Software Supply Chain Failures 🆕 擴大為供應鏈安全
A02: Cryptographic Failures A04: Cryptographic Failures 下降兩位
A03: Injection A05: Injection 下降兩位
A04: Insecure Design A06: Insecure Design 下降兩位
A07: Identification and Authentication Failures A07: Authentication Failures 微調名稱
A08: Software and Data Integrity Failures A08: Software or Data Integrity Failures 微調名稱
A09: Security Logging and Monitoring Failures A09: Security Logging & Alerting Failures 強調「告警」
A10: Server-Side Request Forgery A10: Mishandling of Exceptional Conditions 🆕 全新類別

兩個重點趨勢:供應鏈安全異常處理成為新焦點——這代表現代攻擊已經從「找你程式碼的洞」演進到「從你的依賴和失敗路徑下手」。


三、十大風險逐一擊破:觀念 + 程式碼

A01:2025 — Broken Access Control(權限控制失效)

用生活比喻理解: 你住的大樓,每戶都有各自的門鎖。但如果管理員不小心把所有房卡都設成通用的,任何住戶都能進別人家——這就是權限控制失效。

為什麼排第一? 根據數據,平均 3.73% 的受測應用程式存在此類漏洞,涵蓋 40 個 CWE。2025 年版還把 SSRF(伺服器端請求偽造)也併入了這個類別。

常見攻擊情境:

  • 修改 URL 中的 ID 就能看到別人的訂單:<code>/api/orders/1234</code> → <code>/api/orders/5678</code>
  • 普通使用者直接存取管理員 API
  • 前端隱藏了按鈕,但後端沒有檢查權限

防禦程式碼範例:

// Node.js + Express — 資源層級的權限檢查
async function getOrder(req, res) {
  const order = await Order.findById(req.params.orderId);

  if (!order) {
    return res.status(404).json({ error: '訂單不存在' });
  }

  // ✅ 關鍵:檢查這筆資料是不是屬於當前使用者
  if (order.userId !== req.user.id && req.user.role !== 'admin') {
    return res.status(403).json({ error: '無權存取此訂單' });
  }

  res.json(order);
}
# Python + Django — 使用裝飾器做權限檢查
from django.core.exceptions import PermissionDenied

def check_order_owner(view_func):
    def wrapper(request, order_id, *args, **kwargs):
        order = Order.objects.get(id=order_id)
        # ✅ 確認資源擁有者
        if order.user_id != request.user.id and not request.user.is_staff:
            raise PermissionDenied("你沒有權限存取此訂單")
        return view_func(request, order_id, *args, **kwargs)
    return wrapper
// Java + Spring Boot — 方法層級的權限控制
@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#orderId, authentication)")
@GetMapping("/api/orders/{orderId}")
public ResponseEntity<Order> getOrder(@PathVariable Long orderId) {
    return ResponseEntity.ok(orderService.findById(orderId));
}

飛飛觀點:
權限控制的黃金法則——預設拒絕(Deny by Default)。不是「這個 API 誰不能存取」,而是「這個 API 誰可以存取」。先全部鎖上,再一扇一扇開門。


A02:2025 — Security Misconfiguration(安全設定錯誤)

用生活比喻理解: 你買了一扇頂級防盜門,但安裝時忘了把預設密碼改掉,門鎖出廠密碼是 <code>0000</code>——這就是安全設定錯誤。

為什麼從第五升到第二? 現代系統的行為越來越依賴設定檔(configuration),從雲端服務到容器到 API Gateway,每一層都有大量的設定選項。設定錯一個,門就大開。

常見攻擊情境:

  • 開啟了 Debug 模式上線,錯誤訊息洩露完整堆疊追蹤
  • 雲端 S3 Bucket 設定為公開存取
  • 預設帳號密碼沒有修改(admin/admin)
  • 不必要的 HTTP 方法(PUT、DELETE)沒有關閉

防禦程式碼範例:

// Node.js + Express — 安全標頭與生產環境設定
const helmet = require('helmet');
const app = express();

// ✅ 使用 helmet 一次設定多個安全標頭
app.use(helmet());

// ✅ 根據環境切換設定
if (process.env.NODE_ENV === 'production') {
  app.set('trust proxy', 1);
  app.disable('x-powered-by');  // 不洩露技術棧

  // ❌ 永遠不要在生產環境啟用
  // app.use(errorHandler({ dumpExceptions: true, showStack: true }));
}

// ✅ 安全的錯誤處理:只回傳通用訊息
app.use((err, req, res, next) => {
  console.error(err.stack);  // 記錄到日誌
  res.status(500).json({ 
    error: '系統處理時發生錯誤,請稍後再試'  // 不洩露細節
  });
});
# Docker — 安全設定範例
# ✅ 不要用 root 執行應用程式
FROM node:20-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# ✅ 只開放必要的 port
EXPOSE 3000

# ✅ 設定唯讀檔案系統
# docker run --read-only --tmpfs /tmp myapp

安全設定 Checklist:

- [ ] 移除所有預設帳號密碼
- [ ] 關閉 Debug 模式和詳細錯誤頁面
- [ ] 移除不必要的功能、port、服務
- [ ] 設定適當的 CORS 白名單(不要用 *)
- [ ] HTTP 安全標頭已正確設定
- [ ] 雲端服務權限已最小化
- [ ] 定期審查設定變更

A03:2025 — Software Supply Chain Failures(軟體供應鏈失敗)🆕

用生活比喻理解: 你蓋房子用的水泥是跟供應商買的。如果供應商偷偷在水泥裡摻了劣質材料,你蓋出來的房子表面看不出問題,但結構已經不安全了——這就是供應鏈攻擊。

為什麼是新類別? 2021 年版只關注「過時和有漏洞的元件」,但現代攻擊者已經進化了。他們會入侵上游套件、在 npm 上發布惡意套件、甚至直接汙染 CI/CD 流程。一個被入侵的套件可以在幾小時內影響數百萬個專案。

防禦程式碼範例:

# ✅ 使用 npm audit 檢查已知漏洞
npm audit

# ✅ 鎖定依賴版本(使用 lock 檔案)
npm ci  # 而非 npm install

# ✅ 使用 OWASP Dependency-Check 掃描
dependency-check --project "MyApp" --scan ./

# ✅ 產生 SBOM(軟體物料清單)
npx @cyclonedx/cyclonedx-npm --output-file sbom.json
// package.json — 鎖定版本範圍,避免自動升級到惡意版本
{
  "dependencies": {
    // ❌ 危險:允許任何 minor/patch 版本自動升級
    "some-package": "^1.0.0",

    // ✅ 安全:鎖定精確版本
    "some-package": "1.2.3"
  }
}
# Python — 使用 pip-audit 檢查依賴漏洞
# pip install pip-audit
# pip-audit

# ✅ 鎖定依賴版本
# pip freeze > requirements.txt
# pip install -r requirements.txt --require-hashes

飛飛觀點:
供應鏈安全的核心問題是「你信任誰」。你用的每一個 npm 套件、每一個 Docker image,背後都是別人寫的程式碼。不是說不能用,而是你要知道自己用了什麼、驗證它是安全的、並且持續監控它


A04:2025 — Cryptographic Failures(加密失敗)

用生活比喻理解: 你在日記上加了一把鎖,但鑰匙就貼在日記封面——這就是加密失敗。不是你沒加密,而是你加密的方式有問題。

常見攻擊情境:

  • 密碼用 MD5 或 SHA1 雜湊(已可被破解)
  • 金鑰硬寫在程式碼裡
  • 傳輸資料沒有用 HTTPS
  • 使用已知不安全的加密演算法(如 DES、RC4)

防禦程式碼範例:

// Node.js — 正確的密碼雜湊
const bcrypt = require('bcrypt');

// ✅ 使用 bcrypt 雜湊密碼(cost factor 至少 12)
async function hashPassword(plainPassword) {
  const saltRounds = 12;
  return await bcrypt.hash(plainPassword, saltRounds);
}

// ✅ 驗證密碼
async function verifyPassword(plainPassword, hashedPassword) {
  return await bcrypt.compare(plainPassword, hashedPassword);
}

// ❌ 永遠不要這樣做
// const hash = crypto.createHash('md5').update(password).digest('hex');
# Python — 使用 Argon2 雜湊密碼
from argon2 import PasswordHasher

ph = PasswordHasher(
    time_cost=3,       # 迭代次數
    memory_cost=65536,  # 記憶體用量 (KB)
    parallelism=4       # 平行度
)

# ✅ 雜湊
hashed = ph.hash("user_password")

# ✅ 驗證
try:
    ph.verify(hashed, "user_password")
except Exception:
    print("密碼錯誤")
// Node.js — 金鑰管理:從環境變數讀取,不寫在程式碼裡
// ❌ 硬寫金鑰
// const SECRET_KEY = 'my-super-secret-key-12345';

// ✅ 從環境變數讀取
const SECRET_KEY = process.env.JWT_SECRET;
if (!SECRET_KEY || SECRET_KEY.length < 32) {
  throw new Error('JWT_SECRET 未設定或長度不足');
}

A05:2025 — Injection(注入攻擊)

用生活比喻理解: 你開了一家餐廳,客人在點餐單上寫「滷肉飯一碗」,你的服務生照做。但有一天,有人寫了「把金庫打開」——如果你的服務生照做了,那就是注入攻擊。

注入攻擊雖然從第三名降到第五名,但它仍然是CWE 數量最多的類別(38 個),涵蓋 SQL Injection、XSS、Command Injection 等經典攻擊。

防禦程式碼範例:

// Node.js — SQL Injection 防禦:參數化查詢
const { Pool } = require('pg');
const pool = new Pool();

// ❌ 危險:字串拼接
// const result = await pool.query(
//   <code>SELECT * FROM users WHERE email = '${email}'</code>
// );

// ✅ 安全:參數化查詢
const result = await pool.query(
  'SELECT * FROM users WHERE email = $1',
  [email]
);
# Python + SQLAlchemy — 使用 ORM 防止 SQL Injection
from sqlalchemy import select

# ✅ 安全:ORM 自動處理參數化
stmt = select(User).where(User.email == user_input_email)
result = session.execute(stmt).scalars().first()

# ❌ 危險:raw SQL 字串拼接
# session.execute(f"SELECT * FROM users WHERE email = '{email}'")
// Java — PreparedStatement 防止 SQL Injection
String sql = "SELECT * FROM users WHERE email = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
    stmt.setString(1, userEmail);  // ✅ 參數化
    ResultSet rs = stmt.executeQuery();
}
// Node.js — Command Injection 防禦
const { execFile } = require('child_process');

// ❌ 危險:使用 exec 拼接指令
// exec(<code class="kb-btn">ping ${userInput}</code>);
// 攻擊者輸入: 127.0.0.1; rm -rf /

// ✅ 安全:使用 execFile,參數陣列分離
execFile('ping', ['-c', '4', validatedHost], (error, stdout) => {
  // 安全處理...
});

A06:2025 — Insecure Design(不安全的設計)

用生活比喻理解: 你蓋了一棟房子,牆壁用防火磚、門裝了三道鎖,但你把保險箱設計在大門旁邊——問題不在施工品質,而在設計本身就有缺陷

不安全的設計跟程式碼漏洞不同。它是架構層級的問題,再好的程式碼也救不了糟糕的設計。

防禦策略:

## 安全設計 Checklist

### 認證設計
- [ ] 登入失敗有速率限制(Rate Limiting)
- [ ] 密碼重設流程使用一次性 Token + 過期時間
- [ ] 敏感操作需要二次驗證(如轉帳、修改密碼)

### 業務邏輯
- [ ] 優惠券不能無限使用(伺服器端驗證使用次數)
- [ ] 金額計算在伺服器端進行(不信任前端傳來的價格)
- [ ] 競態條件已處理(如庫存扣減使用資料庫交易)

### 架構設計
- [ ] 使用威脅建模(STRIDE)識別風險
- [ ] 敏感資料有分級保護策略
- [ ] 有 Abuse Case 分析(攻擊者會怎麼用這個功能?)
// Node.js — 防止業務邏輯漏洞:伺服器端驗證優惠券
app.post('/api/checkout', async (req, res) => {
  const { cartItems, couponCode } = req.body;

  // ✅ 伺服器端重新計算價格(不信任前端傳來的金額)
  const serverPrice = await calculatePrice(cartItems);

  // ✅ 伺服器端驗證優惠券
  if (couponCode) {
    const coupon = await Coupon.findOne({ 
      code: couponCode, 
      usedCount: { $lt: '$maxUses' },  // 檢查使用次數
      expiresAt: { $gt: new Date() }   // 檢查過期時間
    });

    if (!coupon) {
      return res.status(400).json({ error: '優惠券無效或已過期' });
    }

    serverPrice.discount = coupon.discountAmount;
  }

  // 用伺服器端計算的金額結帳
  await processPayment(serverPrice);
});

A07:2025 — Authentication Failures(認證失敗)

用生活比喻理解: 大樓的門禁系統有問題——有人忘了設定密碼長度限制,有人的門卡永遠不會過期,有人被鎖在門外卻發現旁邊有個沒上鎖的側門。

防禦程式碼範例:

// Node.js — 安全的 Session 管理
const session = require('express-session');
const RedisStore = require('connect-redis').default;

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  name: '__Host-sid',           // ✅ 使用安全的 cookie 前綴
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,               // ✅ 僅 HTTPS
    httpOnly: true,             // ✅ 禁止 JavaScript 存取
    sameSite: 'strict',         // ✅ 防止 CSRF
    maxAge: 30 * 60 * 1000,    // ✅ 30 分鐘過期
  }
}));
// Node.js — 登入速率限制,防止暴力破解
const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 分鐘
  max: 5,                     // 最多 5 次嘗試
  message: { error: '登入嘗試次數過多,請 15 分鐘後再試' },
  standardHeaders: true,
  legacyHeaders: false,
  // ✅ 以帳號為單位限制,而非只看 IP
  keyGenerator: (req) => req.body.email || req.ip,
});

app.post('/api/login', loginLimiter, loginHandler);
# Python + Flask — JWT Token 驗證
import jwt
from functools import wraps

def require_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        token = request.headers.get('Authorization', '').replace('Bearer ', '')

        if not token:
            return jsonify({'error': '未提供認證 Token'}), 401

        try:
            # ✅ 驗證 Token 並指定演算法(防止 Algorithm Confusion 攻擊)
            payload = jwt.decode(
                token, 
                current_app.config['JWT_SECRET'],
                algorithms=['HS256']  # 明確指定演算法
            )
        except jwt.ExpiredSignatureError:
            return jsonify({'error': 'Token 已過期'}), 401
        except jwt.InvalidTokenError:
            return jsonify({'error': 'Token 無效'}), 401

        return f(*args, **kwargs)
    return decorated

A08:2025 — Software or Data Integrity Failures(軟體或資料完整性失敗)

用生活比喻理解: 你在網路上下載了裝修軟體,但沒有驗證這個軟體是不是被人動過手腳。裝完之後,你的電腦就被植入了後門。

跟 A03(供應鏈安全)不同的是,A08 更關注你自己系統內部的完整性驗證——你信任了不該信任的資料或程式碼。

防禦程式碼範例:

// Node.js — 安全的反序列化:不要直接信任外部 JSON
// ❌ 危險:直接反序列化不可信任的資料
// const data = JSON.parse(untrustedInput);
// Object.assign(user, data);  // Prototype Pollution 風險!

// ✅ 安全:只取需要的欄位(白名單)
function safeUpdate(untrustedInput) {
  const parsed = JSON.parse(untrustedInput);
  return {
    name: typeof parsed.name === 'string' ? parsed.name : undefined,
    email: typeof parsed.email === 'string' ? parsed.email : undefined,
    // 只允許特定欄位,忽略其他所有東西
  };
}
// Node.js — 使用 Subresource Integrity (SRI) 驗證 CDN 資源
// 在 HTML 中引入外部腳本時,加上 integrity 屬性
const scriptTag = `
<script 
  src="https://cdn.example.com/library.min.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8w"
  crossorigin="anonymous">
</script>`;

A09:2025 — Security Logging & Alerting Failures(安全日誌與告警失敗)

用生活比喻理解: 你家裝了監視器,但從來不看畫面、也沒設警報。等小偷來了又走了,你才發現錄影檔三天前就因為硬碟滿了而停止錄影。

2025 年版特別把名稱從「Monitoring」改為「Alerting」,強調光有 Log 不夠,你需要即時告警

防禦程式碼範例:

// Node.js — 安全日誌的正確做法
const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'security.log' })
  ],
});

// ✅ 記錄安全相關事件
function logSecurityEvent(event) {
  logger.warn('SECURITY_EVENT', {
    type: event.type,           // 'LOGIN_FAILURE', 'ACCESS_DENIED', etc.
    userId: event.userId,
    ip: event.ip,
    userAgent: event.userAgent,
    timestamp: new Date().toISOString(),
    // ❌ 永遠不記錄這些
    // password: event.password,
    // creditCard: event.creditCard,
    // sessionToken: event.token,
  });
}

// ✅ 應該記錄的事件
// - 登入成功/失敗(特別是連續失敗)
// - 權限被拒絕的存取嘗試
// - 輸入驗證失敗(可能是攻擊探測)
// - 敏感操作(密碼變更、權限修改、資料匯出)
# Python — 異常登入偵測與告警
import redis
from datetime import datetime, timedelta

r = redis.Redis()

def check_login_anomaly(user_id: str, ip: str):
    key = f"login_failures:{user_id}"
    failures = r.incr(key)
    r.expire(key, 900)  # 15 分鐘視窗

    # ✅ 連續失敗超過閾值,觸發告警
    if failures >= 5:
        send_alert(
            level="HIGH",
            message=f"帳號 {user_id} 在 15 分鐘內登入失敗 {failures} 次",
            metadata={"ip": ip, "timestamp": datetime.utcnow().isoformat()}
        )

A10:2025 — Mishandling of Exceptional Conditions(異常狀況處理不當)🆕

用生活比喻理解: 你家的自動門在正常情況下很安全——刷卡才能進。但有一天停電了,自動門的預設行為是「打開」而不是「鎖上」——這就是「失敗時開放(Failing Open)」,也是這個新類別的核心問題。

這是 2025 年全新加入的類別,涵蓋 24 個 CWE,專注於系統在遇到異常時的行為:錯誤處理不當、邏輯錯誤、失敗時開放等。

防禦程式碼範例:

// Node.js — Fail Secure:異常時預設拒絕
async function checkPermission(userId, resource) {
  try {
    const permission = await permissionService.check(userId, resource);
    return permission.allowed;
  } catch (error) {
    // ✅ Fail Secure:出錯時預設拒絕存取
    logger.error('權限檢查失敗', { userId, resource, error: error.message });
    return false;  // 拒絕存取

    // ❌ Fail Open:出錯時預設允許(危險!)
    // return true;
  }
}
// Node.js — 安全的錯誤處理:不洩露內部資訊
app.use((err, req, res, next) => {
  // ✅ 記錄完整的錯誤資訊到日誌
  logger.error('Unhandled error', {
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });

  // ✅ 回傳給使用者的只有通用訊息
  const statusCode = err.statusCode || 500;
  res.status(statusCode).json({
    error: statusCode === 500 
      ? '系統處理時發生錯誤,請稍後再試'
      : err.userMessage || '請求處理失敗',
    // ❌ 永遠不要回傳這些
    // stack: err.stack,
    // query: err.sql,
    // config: err.config,
  });
});
# Python — 資源清理:確保異常時也能正確釋放資源
import contextlib

# ✅ 使用 context manager 確保資源被釋放
@contextlib.contextmanager
def secure_db_connection():
    conn = None
    try:
        conn = database.connect()
        yield conn
    except DatabaseError as e:
        logger.error(f"資料庫錯誤: {e}")
        raise  # 重新拋出,讓上層處理
    finally:
        # ✅ 無論成功或失敗,都要關閉連線
        if conn:
            conn.close()

飛飛觀點:
這個新類別讓我特別興奮。很多開發者只測試「快樂路徑(Happy Path)」——功能正常時怎麼跑。但攻擊者專門找的是「不快樂路徑」——系統出錯時會發生什麼。你的 catch 區塊和 else 分支,才是安全的戰場。


四、2021 vs. 2025 對照速查表

排名 2025 類別 核心防禦策略 對應 SSDLC 階段
A01 Broken Access Control 預設拒絕 + 資源層級權限檢查 設計 + 實作
A02 Security Misconfiguration 安全基線 + 自動化設定掃描 部署 + 維運
A03 Software Supply Chain Failures 🆕 SBOM + 依賴掃描 + 簽章驗證 實作 + 驗證
A04 Cryptographic Failures 強雜湊 + 金鑰管理 + TLS 設計 + 實作
A05 Injection 參數化查詢 + 輸入驗證 + 輸出編碼 實作
A06 Insecure Design 威脅建模 + Abuse Case + 安全設計審查 需求 + 設計
A07 Authentication Failures MFA + 速率限制 + 安全 Session 設計 + 實作
A08 Software or Data Integrity SRI + 簽章 + 安全反序列化 實作 + 部署
A09 Logging & Alerting Failures 結構化日誌 + 即時告警 + 不記錄敏感資料 實作 + 維運
A10 Mishandling of Exceptional Conditions 🆕 Fail Secure + 安全錯誤處理 + 資源清理 實作

五、團隊落地建議:如何把 OWASP Top 10 融入日常開發

建議一:把 Top 10 變成 Code Review Checklist

不要把 OWASP Top 10 當成培訓教材看一次就好。把它變成每次 Code Review 時的檢查項目:

## OWASP Top 10 Code Review 快速檢查

### 每次 PR 必查
- [ ] A01: 新的 API 端點有做權限檢查嗎?
- [ ] A05: SQL 查詢有用參數化嗎?使用者輸入有驗證嗎?
- [ ] A10: catch 區塊的處理安全嗎?不會洩露資訊或 Fail Open?

### 設定變更時查
- [ ] A02: 新的設定是否遵循最小權限?有沒有預設不安全的值?

### 加入新依賴時查
- [ ] A03: 這個套件可信嗎?最後更新時間?有已知漏洞嗎?

### 涉及認證/加密時查
- [ ] A04: 使用的加密演算法是否足夠強?金鑰管理方式安全嗎?
- [ ] A07: Session 管理是否正確?有速率限制嗎?

建議二:從風險最高的三項開始

不要試圖一次做完十項。根據數據,先搞定前三名就能大幅降低風險:

  1. A01 Broken Access Control(影響最廣)
  2. A02 Security Misconfiguration(最容易發生)
  3. A05 Injection(後果最嚴重)

建議三:在 CI/CD 中自動化檢測

# GitHub Actions — 自動化安全掃描範例
name: Security Scan
on: [push, pull_request]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      # A03: 依賴漏洞掃描
      - name: Dependency Check
        run: npm audit --audit-level=high

      # A05: 靜態程式碼分析(找 Injection 漏洞)
      - name: SAST Scan
        uses: returntocorp/semgrep-action@v1
        with:
          config: p/owasp-top-ten

      # A02: 基礎設施設定掃描
      - name: IaC Scan
        run: docker run --rm -v $(pwd):/src checkmarx/kics:latest scan -p /src

六、常見問題 FAQ

Q1:OWASP Top 10 2025 跟 2021 的差別大嗎?需要重新學嗎?

核心概念沒有大變,但有兩個重要新增:A03 軟體供應鏈失敗和 A10 異常狀況處理不當。如果你已經熟悉 2021 版,重點學習這兩個新類別,再注意各項目排名變化背後的趨勢即可。

Q2:我是前端工程師,OWASP Top 10 跟我有關嗎?

當然有。XSS(包含在 A05 Injection 中)跟前端直接相關,A01 的權限控制前端也有責任(不要在前端儲存敏感的權限判斷邏輯),A02 的 CORS 和安全標頭設定也需要前端配合。而且,前端也有自己的依賴供應鏈(npm 套件),A03 也是你的事。

Q3:做了 OWASP Top 10 的防禦就夠了嗎?

不夠,但已經很好了。OWASP Top 10 只涵蓋最常見的十大風險。你的系統可能還有特定於業務邏輯的漏洞、特定技術棧的問題等。但如果你能把這十項都做到位,你已經超越了大部分的應用程式安全水準。想更進一步,可以參考 OWASP ASVS(更詳細的驗證標準)。

Q4:Vibe Coding 時代,AI 生成的程式碼要注意哪些 OWASP 風險?

AI 生成的程式碼最常出現的問題包括:SQL 字串拼接(A05)、缺少權限檢查(A01)、硬編碼金鑰(A04)、以及缺少錯誤處理(A10)。建議把 AI 當成一個「很快但不懂安全的實習生」——它寫完之後,你要用 OWASP Top 10 Checklist 過一遍。


七、結語:OWASP Top 10 不是終點,而是起點

OWASP Top 10 已經發展了超過二十年,從 2003 年的第一版到 2025 年的第八版,它一直在反映一個事實:軟體安全的挑戰不斷演進,但防禦的基本功永遠重要。

2025 年版告訴我們幾件事:

  • 權限控制永遠是最大挑戰(A01 連續兩屆第一名)
  • 你的依賴就是你的攻擊面(A03 供應鏈安全成為新類別)
  • 系統怎麼失敗比怎麼成功更重要(A10 異常處理成為新焦點)
  • 安全不只是寫好程式碼,更是設好設定(A02 躍升至第二名)

回到我們蓋房子的比喻——OWASP Top 10 就是建築界的「十大必檢安全項目」。你不一定要拿到完美的分數,但你至少要知道這些檢查項目是什麼,然後一項一項地做好。

安全不是恐懼,而是創造的基礎。
當你知道怎麼防禦這十大風險,你就能更有信心地打造產品——因為你知道,你的房子不會因為一個小疏忽就倒塌。


延伸閱讀

[安全程式設計] 001 安全編碼基礎:輸入驗證與輸出編碼——永不信任使用者輸入的防禦心法

「你可以相信使用者會正確操作你的系統嗎?不行。
你可以相信攻擊者不會亂塞東西進來嗎?更不行。
安全編碼的第一課,就是學會對所有輸入說:『我不信你,但我會好好處理你。』」
— SSDLC by 飛飛


一、安全編碼是什麼?為什麼它是你的防火建材?

在 SSDLC 的「蓋房子」旅程中,我們已經走過了安全需求定義(確認要防震防火防盜)、安全設計(畫好建築藍圖、標好消防通道)。現在,我們來到了階段三:安全實作(Implementation)——真正動手施工的階段。

想像你是一個建築工人。建築師已經畫好了完美的藍圖,結構技師也算好了承重。但如果你施工時用了劣質的電線、沒有接地線的插座、不防火的隔間材料——那再好的設計也白搭。一場電線走火,整棟房子就毀了。

安全編碼就是軟體開發中的「使用合格建材」。 不管你的需求寫得多完整、設計做得多周全,如果程式碼本身有漏洞,攻擊者照樣長驅直入。

而在所有安全編碼的原則中,有兩條是最基礎、最重要、卻也最常被忽略的:

  1. 輸入驗證(Input Validation)——永不信任任何來自外部的資料
  2. 輸出編碼(Output Encoding)——確保輸出到不同環境時不會被誤解為指令

這兩條原則就像建築的「防火建材」和「接地線」。它們不炫目、不花俏,但少了它們,系統隨時可能「走火」。

根據 OWASP Top 10 的統計,注入攻擊(Injection)和跨站腳本攻擊(XSS) 長年盤踞在最危險的漏洞前幾名,而這兩大類攻擊的根本原因,幾乎都是輸入驗證不足或輸出編碼缺失。換句話說,光是把這兩件事做好,你就能擋下超過一半的常見攻擊。


二、永不信任使用者輸入:安全編碼的第一鐵律

2.1 什麼叫做「不信任使用者輸入」?

很多開發者聽到「不信任使用者輸入」,第一反應是:「我的使用者又不是駭客,幹嘛這麼多疑?」

讓我用一個生活場景來解釋。

假設你開了一家餐廳,門口有個點餐表單讓客人填寫。正常客人會寫「滷肉飯一碗、味噌湯一碗」。但如果有個人在表單上寫:「請把金庫打開,把錢放到後門」——你的服務生不應該照做吧?

問題是,在軟體世界裡,很多系統就是這樣照做的。使用者在搜尋框裡輸入一段 SQL 指令,系統就真的去資料庫執行了;使用者在評論欄裡放了一段 JavaScript,系統就真的在別人的瀏覽器上跑了。

「不信任使用者輸入」不是對使用者的不尊重,而是對系統的負責。

2.2 哪些東西算是「使用者輸入」?

這裡有一個很多初學者會犯的錯:只驗證表單欄位,卻忘了其他入口。

事實上,所有來自系統外部的資料都是不可信任的輸入

輸入來源 範例 常被忽略?
表單欄位 使用者名稱、密碼、Email 通常會驗證
URL 參數 <code>?id=123</code>、<code>?page=2</code> 經常被忽略
HTTP Headers Cookie、User-Agent、Referer 很少被驗證
API Request Body JSON payload、XML 資料 部分會驗證
檔案上傳 圖片、文件、CSV 驗證不完整
第三方 API 回應 金流回呼、社群登入回傳 幾乎不驗證
資料庫既有資料 之前存入的使用者內容 幾乎不驗證
環境變數 / 設定檔 <code>.env</code> 中的值 極少驗證

飛飛觀點:
你可能會說:「資料庫裡的資料是我自己存的,為什麼也不能信任?」因為那些資料可能是之前沒有做好驗證時存進去的「髒資料」。就像餐廳冰箱裡的食材,如果進貨時沒有檢查,你怎麼知道裡面沒有過期品?輸出時再次編碼,就是你的最後一道防線。

2.3 輸入驗證的五大策略

輸入驗證不是一招打天下,而是要根據場景選擇合適的策略:

策略一:白名單驗證(Allowlist)— 最推薦

只接受你明確允許的值,其他一律拒絕。就像餐廳的菜單——客人只能點菜單上有的東西。

// ✅ 白名單驗證:只接受預定義的排序欄位
const ALLOWED_SORT_FIELDS = ['name', 'price', 'created_at', 'rating'];

function validateSortField(field) {
  if (!ALLOWED_SORT_FIELDS.includes(field)) {
    throw new Error('不支援的排序欄位');
  }
  return field;
}

// ✅ 白名單驗證:只接受特定的商品分類
const ALLOWED_CATEGORIES = ['electronics', 'clothing', 'food', 'books'];

app.get('/api/products', (req, res) => {
  const category = req.query.category;
  if (category && !ALLOWED_CATEGORIES.includes(category)) {
    return res.status(400).json({ error: '無效的商品分類' });
  }
  // 安全地查詢...
});

策略二:型別與格式驗證(Type & Format)

確認輸入的資料型別和格式符合預期。

const { body, param, query, validationResult } = require('express-validator');

// ✅ 驗證各種資料格式
app.post('/api/users',
  body('email')
    .isEmail().withMessage('Email 格式不正確')
    .normalizeEmail(),
  body('age')
    .isInt({ min: 0, max: 150 }).withMessage('年齡必須是 0-150 的整數'),
  body('phone')
    .matches(/^09\d{8}$/).withMessage('請輸入有效的台灣手機號碼'),
  body('username')
    .isAlphanumeric().withMessage('使用者名稱只能包含英文字母和數字')
    .isLength({ min: 3, max: 20 }).withMessage('使用者名稱長度須為 3-20 字'),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    // 通過驗證,安全處理...
  }
);

策略三:長度限制(Length Restriction)

限制輸入的長度,防止緩衝區溢位或資源耗盡攻擊。

// ✅ 針對不同欄位設定合理的長度限制
const LENGTH_LIMITS = {
  username:    { min: 3,  max: 20 },
  password:    { min: 12, max: 128 },
  email:       { min: 5,  max: 254 },
  comment:     { min: 1,  max: 2000 },
  searchQuery: { min: 1,  max: 100 },
  address:     { min: 5,  max: 200 },
};

function validateLength(field, value) {
  const limit = LENGTH_LIMITS[field];
  if (!limit) throw new Error(<code class="kb-btn">未定義的欄位: ${field}</code>);
  if (value.length < limit.min || value.length > limit.max) {
    throw new Error(<code>${field} 長度須為 ${limit.min}-${limit.max} 字</code>);
  }
  return value;
}

策略四:範圍與邊界驗證(Range & Boundary)

確認數值在合理範圍內。

// ✅ 商品價格與數量的邊界驗證
app.post('/api/orders',
  body('quantity')
    .isInt({ min: 1, max: 99 })
    .withMessage('購買數量須為 1-99'),
  body('price')
    .isFloat({ min: 0.01, max: 9999999.99 })
    .withMessage('價格範圍不正確'),
  body('discount')
    .optional()
    .isFloat({ min: 0, max: 1 })
    .withMessage('折扣須為 0-1 之間'),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }

    // ✅ 業務邏輯層的額外驗證
    const { quantity, price, discount } = req.body;
    const totalAmount = quantity * price * (1 - (discount || 0));

    // 防止計算後金額異常(如負數或超大數值)
    if (totalAmount <= 0 || totalAmount > 10000000) {
      return res.status(400).json({ error: '訂單金額異常' });
    }
    // 安全處理...
  }
);

策略五:黑名單過濾(Denylist)— 最不推薦,但有時是補充

阻擋已知的危險模式。這是最弱的防禦,因為攻擊者總能找到新的繞過方式,但作為補充手段仍有價值。

// ⚠️ 黑名單只能作為「補充」,不能作為唯一防線
const DANGEROUS_PATTERNS = [
  /(<script[\s>])/gi,          // 基本 XSS
  /(javascript\s*:)/gi,        // JavaScript 偽協定
  /(on\w+\s*=)/gi,             // 事件處理器
  /(union\s+select)/gi,        // SQL Injection
  /(\.\.\/)|(\.\.\\)/g,        // Path Traversal
];

function containsDangerousPattern(input) {
  return DANGEROUS_PATTERNS.some(pattern => pattern.test(input));
}

// ⚠️ 注意:這不能取代參數化查詢和輸出編碼!
// 它只是多一層「告警」機制
if (containsDangerousPattern(userInput)) {
  logger.warn('偵測到可疑輸入', { input: userInput, ip: req.ip });
  // 可以選擇拒絕,或繼續處理但加強監控
}

2.4 前端驗證 vs. 後端驗證:為什麼只做前端不夠?

這是新手最常犯的錯誤之一:「我前端有用 JavaScript 驗證了,應該沒問題吧?」

面向 前端驗證 後端驗證
目的 提升使用者體驗 真正的安全防線
可被繞過? 可以(用 DevTools、Postman、curl) 不行(除非伺服器有漏洞)
速度 即時回饋,不需等伺服器 需要一次請求
必要性 建議有,但非必須 絕對必須
// ❌ 只有前端驗證 = 沒有驗證
// 攻擊者可以直接用 curl 繞過前端
// curl -X POST http://your-api.com/login -d '{"email":"' OR 1=1--"}'

// ✅ 前端驗證 + 後端驗證 = 正確做法
// 前端:即時回饋使用者
// 後端:真正的安全關卡

飛飛觀點:
把前端驗證想成餐廳門口的菜單提示:「本店不賣酒」。正常客人看到會自動遵守。但如果有人硬要帶酒進來,你總不能只靠門口的告示牌擋吧?你需要服務生在入場時真正檢查——這就是後端驗證。


三、輸出編碼:防止你的資料被「誤解」為指令

3.1 為什麼輸入驗證做好了,還需要輸出編碼?

想像你收到一封信,內容寫著:「請把門打開」。如果這封信是你朋友寫的,你可能會照做。但如果這封信是有人偽造的呢?

在軟體世界裡,資料和指令的邊界非常脆弱。你以為存在資料庫裡的是一段文字,但當這段文字被放到 HTML 頁面上時,瀏覽器可能把它當成程式碼來執行。

這就是 XSS(跨站腳本攻擊)的本質:攻擊者的輸入被當成了指令

輸出編碼的目的,就是確保資料永遠被當作資料,而不會被誤解為指令

3.2 不同的輸出環境,需要不同的編碼方式

這是一個非常重要但經常被忽略的觀念:同一筆資料,放在不同的環境中,需要不同的編碼方式

輸出環境 危險所在 編碼方式 範例
HTML 內容 <code><script></code> 會被執行 HTML Entity 編碼 <code><</code> → <code><</code>
HTML 屬性 <code>" onmouseover="alert(1)</code> 可注入事件 HTML Attribute 編碼 <code>"</code> → <code>"</code>
JavaScript 字串中的 <code>'</code> 可跳脫字串邊界 JavaScript 編碼 <code>'</code> → <code>\x27</code>
URL 特殊字元改變 URL 結構 URL 編碼 <code>&</code> → <code>%26</code>
CSS <code>expression()</code> 可執行 JS CSS 編碼 移除 <code>expression</code>、<code>url()</code>
SQL <code>'</code> 可跳脫字串邊界 參數化查詢(不是編碼) 使用 Prepared Statement

讓我用蓋房子的比喻來說明:你買了一桶水泥(資料),要把它用在不同地方。砌牆的水泥要加粗砂(HTML 編碼),填地基的水泥要加碎石(SQL 參數化),做裝飾面的水泥要加細砂(JavaScript 編碼)。同一種原料,用途不同,處理方式就不同。

3.3 HTML 輸出編碼:防止 XSS 的第一道防線

const he = require('he');

// ✅ 方法一:使用 he 套件做 HTML Entity 編碼
function escapeHTML(input) {
  return he.encode(input);
}

// 範例
const userComment = '<script>alert("XSS")</script>';
const safeComment = escapeHTML(userComment);
// 輸出:<script>alert("XSS")</script>
// 瀏覽器會顯示文字,而非執行腳本

// ✅ 方法二:使用模板引擎的自動轉義(推薦)
// EJS 預設會轉義
// <%= userComment %>     ← 會自動 HTML 編碼(安全)
// <%- userComment %>     ← 不會編碼(危險!除非你確定內容安全)

// ✅ 方法三:使用 DOMPurify 做 HTML 清理(允許部分 HTML 時使用)
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);

function sanitizeHTML(dirty) {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href', 'title'],
  });
}

// 範例:允許部分格式化標籤,但移除危險內容
const richComment = '<b>好評</b><script>alert("hack")</script><a href="javascript:alert(1)">連結</a>';
const cleanComment = sanitizeHTML(richComment);
// 輸出:<b>好評</b><a>連結</a>
// script 被移除,javascript: 偽協定被清除

3.4 SQL 注入防禦:參數化查詢,不是字串拼接

SQL Injection 是史上最經典的注入攻擊。防禦方式不是「編碼」,而是從根本上把資料和指令分開——使用參數化查詢。

const { Pool } = require('pg');
const pool = new Pool();

// ❌ 危險寫法:字串拼接(SQL Injection 的溫床)
app.get('/api/products', async (req, res) => {
  const category = req.query.category;
  // 攻擊者可以輸入:' OR 1=1; DROP TABLE products; --
  const result = await pool.query(
    <code>SELECT * FROM products WHERE category = '${category}'</code>
  );
  res.json(result.rows);
});

// ✅ 安全寫法:參數化查詢
app.get('/api/products', async (req, res) => {
  const category = req.query.category;
  const result = await pool.query(
    'SELECT * FROM products WHERE category = $1',
    [category]  // 資料和指令完全分離
  );
  res.json(result.rows);
});

// ✅ 使用 ORM(Sequelize)也是安全的
const products = await Product.findAll({
  where: { category: req.query.category }  // ORM 會自動參數化
});

// ❌ 但注意!ORM 的 raw query 一樣危險
// 避免這樣寫:
const [results] = await sequelize.query(
  <code>SELECT * FROM products WHERE category = '${category}'</code>  // 又回到字串拼接了!
);

// ✅ ORM 的 raw query 安全寫法
const [results] = await sequelize.query(
  'SELECT * FROM products WHERE category = :category',
  { replacements: { category: req.query.category } }
);

3.5 JavaScript 輸出環境的編碼

當你需要將使用者資料嵌入 JavaScript 程式碼中時:

// ❌ 危險寫法:直接嵌入
app.get('/profile', (req, res) => {
  const username = req.user.name; // 可能包含 '; alert('XSS'); //
  res.send(`
    <script>
      var username = '${username}';  // 攻擊者可以跳脫字串
    </script>
  `);
});

// ✅ 安全寫法:使用 JSON.stringify + HTML 編碼
app.get('/profile', (req, res) => {
  const username = req.user.name;
  // JSON.stringify 會正確轉義引號和特殊字元
  const safeData = JSON.stringify(username)
    .replace(/</g, '\\u003c')    // 防止 </script> 閉合標籤
    .replace(/>/g, '\\u003e')
    .replace(/&/g, '\\u0026');

  res.send(`
    <script>
      var username = ${safeData};
    </script>
  `);
});

// ✅ 更推薦:使用 data attribute + JavaScript 讀取
app.get('/profile', (req, res) => {
  const username = he.encode(req.user.name);
  res.send(`
    <div id="profile" data-username="${username}"></div>
    <script>
      var username = document.getElementById('profile').dataset.username;
    </script>
  `);
});

3.6 URL 編碼

// ❌ 危險寫法:直接拼接 URL
const redirectUrl = req.query.returnTo;
res.redirect(redirectUrl);  
// 攻擊者可以輸入:javascript:alert(1) 或 https://evil.com

// ✅ 安全寫法:驗證 + 編碼
function safeRedirect(req, res) {
  const returnTo = req.query.returnTo;

  // 1. 白名單驗證:只允許自家網域
  const ALLOWED_HOSTS = ['www.mystore.com.tw', 'mystore.com.tw'];

  try {
    const url = new URL(returnTo, 'https://mystore.com.tw');
    if (!ALLOWED_HOSTS.includes(url.hostname)) {
      return res.redirect('/');  // 不在白名單,導回首頁
    }
    // 2. 只允許 HTTPS
    if (url.protocol !== 'https:') {
      return res.redirect('/');
    }
    res.redirect(url.toString());
  } catch (e) {
    res.redirect('/');  // URL 解析失敗,導回首頁
  }
}

四、實戰案例:台灣電商平台的商品評論功能

讓我們用一個完整的台灣場景,從頭到尾走一遍輸入驗證與輸出編碼的實作。

場景描述

你正在為一個台灣電商平台開發「商品評論」功能。使用者可以為購買過的商品留下文字評論和星等評分。

Step 1:列出所有輸入來源

輸入 型別 範圍 來源
productId 整數 > 0 URL 參數
rating 整數 1-5 Request Body
comment 字串 1-2000 字 Request Body
userId 整數 > 0 JWT Token(伺服器端解析)

Step 2:定義驗證規則

const express = require('express');
const { body, param, validationResult } = require('express-validator');
const he = require('he');
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const DOMPurify = createDOMPurify(new JSDOM('').window);

const app = express();
app.use(express.json({ limit: '10kb' }));  // 限制 body 大小

// 評論的驗證規則
const reviewValidation = [
  param('productId')
    .isInt({ min: 1 })
    .withMessage('商品 ID 格式錯誤'),
  body('rating')
    .isInt({ min: 1, max: 5 })
    .withMessage('評分須為 1-5 的整數'),
  body('comment')
    .trim()
    .isLength({ min: 1, max: 2000 })
    .withMessage('評論內容須為 1-2000 字')
    .customSanitizer(value => {
      // 移除所有 HTML 標籤(評論不需要 HTML 格式)
      return DOMPurify.sanitize(value, { ALLOWED_TAGS: [] });
    }),
];

Step 3:完整的 API 實作

const { Pool } = require('pg');
const pool = new Pool();

app.post('/api/products/:productId/reviews',
  authenticateToken,     // 驗證 JWT(你是誰)
  reviewValidation,      // 輸入驗證(你給的資料合法嗎)
  async (req, res) => {
    // 檢查驗證結果
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ 
        error: '輸入驗證失敗',
        details: errors.array().map(e => e.msg)
      });
    }

    const productId = parseInt(req.params.productId);
    const { rating, comment } = req.body;
    const userId = req.user.id;  // 從已驗證的 JWT 取得

    try {
      // 業務邏輯驗證:使用者是否購買過此商品
      const purchaseCheck = await pool.query(
        'SELECT id FROM orders WHERE user_id = $1 AND product_id = $2 AND status = $3',
        [userId, productId, 'completed']
      );

      if (purchaseCheck.rows.length === 0) {
        return res.status(403).json({ error: '只有購買過的客戶可以評論' });
      }

      // 業務邏輯驗證:是否已經評論過
      const existingReview = await pool.query(
        'SELECT id FROM reviews WHERE user_id = $1 AND product_id = $2',
        [userId, productId]
      );

      if (existingReview.rows.length > 0) {
        return res.status(409).json({ error: '您已經評論過此商品' });
      }

      // ✅ 使用參數化查詢寫入資料庫
      const result = await pool.query(
        `INSERT INTO reviews (user_id, product_id, rating, comment, created_at)
         VALUES ($1, $2, $3, $4, NOW())
         RETURNING id, rating, comment, created_at`,
        [userId, productId, rating, comment]
      );

      // 記錄日誌(不包含評論全文,避免日誌過大)
      logger.info('新評論建立', { 
        reviewId: result.rows[0].id,
        userId, 
        productId,
        rating 
      });

      res.status(201).json({ 
        message: '評論已送出',
        review: result.rows[0]
      });

    } catch (error) {
      logger.error('評論建立失敗', { error: error.message, userId, productId });
      // ✅ 不洩露內部錯誤細節
      res.status(500).json({ error: '系統處理時發生錯誤,請稍後再試' });
    }
  }
);

Step 4:安全地顯示評論(輸出編碼)

// ✅ API 回傳評論列表時的輸出處理
app.get('/api/products/:productId/reviews', async (req, res) => {
  const productId = parseInt(req.params.productId);

  if (isNaN(productId) || productId < 1) {
    return res.status(400).json({ error: '商品 ID 格式錯誤' });
  }

  const result = await pool.query(
    `SELECT r.id, r.rating, r.comment, r.created_at, u.username
     FROM reviews r
     JOIN users u ON r.user_id = u.id
     WHERE r.product_id = $1
     ORDER BY r.created_at DESC
     LIMIT 50`,
    [productId]
  );

  // ✅ 輸出時再次編碼,作為最後一道防線
  const safeReviews = result.rows.map(review => ({
    id: review.id,
    rating: review.rating,
    comment: he.encode(review.comment),       // HTML 編碼
    username: he.encode(review.username),      // HTML 編碼
    createdAt: review.created_at,
  }));

  res.json({ reviews: safeReviews });
});

Step 5:前端安全顯示

// ✅ 前端使用 textContent 而非 innerHTML
function renderReview(review) {
  const reviewDiv = document.createElement('div');
  reviewDiv.className = 'review-card';

  const username = document.createElement('span');
  username.textContent = review.username;  // textContent 不會解析 HTML

  const comment = document.createElement('p');
  comment.textContent = review.comment;    // 安全!不會執行任何腳本

  const rating = document.createElement('span');
  rating.textContent = '⭐'.repeat(review.rating);

  reviewDiv.appendChild(username);
  reviewDiv.appendChild(rating);
  reviewDiv.appendChild(comment);

  return reviewDiv;
}

// ❌ 危險寫法:永遠不要這樣做
// reviewDiv.innerHTML = <code><p>${review.comment}</p></code>;
// 如果 comment 包含 <script>,就會被執行!

五、安全編碼 Checklist:輸入驗證與輸出編碼

把這份清單貼在你的工位上(或加進你的 Code Review checklist):

輸入驗證 Checklist

## 輸入驗證 Checklist

### 基本原則
- [ ] 所有輸入都在**伺服器端**進行驗證(前端驗證只是輔助)
- [ ] 使用**白名單**策略,而非黑名單
- [ ] 驗證失敗時,回傳通用錯誤訊息(不洩露系統細節)

### 各欄位驗證
- [ ] 驗證資料型別(字串、整數、布林)
- [ ] 驗證長度/大小限制
- [ ] 驗證格式(Email、電話、身分證、日期)
- [ ] 驗證數值範圍(最小值、最大值)
- [ ] 使用白名單限定可接受的值(下拉選單、排序欄位)

### 特殊輸入
- [ ] 檔案上傳:驗證 MIME Type + Magic Number(不只看副檔名)
- [ ] 檔案上傳:限制檔案大小
- [ ] URL 參數:驗證並限定可接受的路徑
- [ ] JSON/XML Body:限制 Request Body 大小
- [ ] JSON/XML Body:驗證資料結構(schema validation)

### 進階防護
- [ ] 對同一 IP 或帳號實施速率限制(Rate Limiting)
- [ ] 記錄驗證失敗的日誌(供資安監控使用)
- [ ] 多層驗證:API Gateway → 中間件 → 業務邏輯層

輸出編碼 Checklist

## 輸出編碼 Checklist

### 基本原則
- [ ] 根據**輸出環境**選擇正確的編碼方式
- [ ] 在資料**輸出時**編碼,而非輸入時(保留原始資料)
- [ ] 使用經過驗證的編碼函式庫,不要自己寫

### HTML 環境
- [ ] 使用者資料放入 HTML 內容時,進行 HTML Entity 編碼
- [ ] 使用者資料放入 HTML 屬性時,進行屬性編碼且加上引號
- [ ] 如需允許部分 HTML,使用 DOMPurify 做白名單清理
- [ ] 設定 Content-Security-Policy Header 限制 inline script

### JavaScript 環境
- [ ] 使用者資料放入 JS 字串時,用 JSON.stringify + 額外轉義
- [ ] 優先使用 data attribute 傳遞資料,避免直接嵌入 <script>

### SQL 環境
- [ ] 所有 SQL 查詢使用參數化查詢(Prepared Statement)
- [ ] ORM 的 raw query 也要使用參數化
- [ ] 絕對不使用字串拼接組合 SQL

### URL 環境
- [ ] 使用者資料放入 URL 時,進行 URL 編碼
- [ ] 重導向 URL 使用白名單驗證

### HTTP Header
- [ ] 設定 Content-Type 為正確的 MIME Type
- [ ] 設定 X-Content-Type-Options: nosniff
- [ ] 設定 Content-Security-Policy

六、團隊落地建議:讓安全編碼變成日常習慣

建議一:建立團隊的安全編碼規範文件

不要只是口頭說「大家要注意安全」。把規範寫成文件,放在 Git 裡面版本控制:

/project
  /docs
    secure-coding-standard.md   ← 團隊的安全編碼規範
  /src
    /middleware
      input-validator.js        ← 共用的輸入驗證中間件
      output-encoder.js         ← 共用的輸出編碼工具
    /config
      validation-rules.js       ← 各欄位的驗證規則定義

建議二:封裝共用的安全工具

把輸入驗證和輸出編碼封裝成團隊共用的工具,降低每個開發者「重新發明輪子」的機會:

// /src/utils/security.js — 團隊共用的安全工具
const he = require('he');
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const DOMPurify = createDOMPurify(new JSDOM('').window);

const security = {
  // HTML 編碼
  encodeHTML(input) {
    if (typeof input !== 'string') return '';
    return he.encode(input);
  },

  // HTML 清理(允許部分標籤)
  sanitizeHTML(input, options = {}) {
    if (typeof input !== 'string') return '';
    return DOMPurify.sanitize(input, {
      ALLOWED_TAGS: options.allowedTags || ['b', 'i', 'em', 'strong', 'p', 'br'],
      ALLOWED_ATTR: options.allowedAttrs || [],
      ...options,
    });
  },

  // 純文字清理(移除所有 HTML)
  stripHTML(input) {
    if (typeof input !== 'string') return '';
    return DOMPurify.sanitize(input, { ALLOWED_TAGS: [] });
  },

  // 安全的 JSON 嵌入 HTML
  safeJSONEmbed(data) {
    return JSON.stringify(data)
      .replace(/</g, '\\u003c')
      .replace(/>/g, '\\u003e')
      .replace(/&/g, '\\u0026')
      .replace(/'/g, '\\u0027');
  },
};

module.exports = security;

建議三:在 Code Review 時加入安全檢查項目

在團隊的 Code Review 流程中,加入以下安全問題:

  1. 這段程式碼有接受外部輸入嗎?有做驗證嗎?
  2. 有使用字串拼接組合 SQL 嗎?
  3. 使用者資料有被輸出到 HTML/JS/URL 嗎?有做編碼嗎?
  4. 錯誤處理有洩露系統內部資訊嗎?

建議四:使用 ESLint 安全規則自動檢查

// .eslintrc.js
module.exports = {
  plugins: ['security'],
  extends: ['plugin:security/recommended'],
  rules: {
    'security/detect-object-injection': 'warn',
    'security/detect-non-literal-regexp': 'warn',
    'security/detect-unsafe-regex': 'error',
    'security/detect-buffer-noassert': 'error',
    'security/detect-eval-with-expression': 'error',
    'security/detect-no-csrf-before-method-override': 'error',
    'security/detect-possible-timing-attacks': 'warn',
  },
};

建議五:設定 HTTP 安全標頭作為額外防線

const helmet = require('helmet');

app.use(helmet());

// 或者手動設定關鍵的安全標頭
app.use((req, res, next) => {
  // 防止 XSS:限制腳本來源
  res.setHeader('Content-Security-Policy', 
    "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'");

  // 防止 MIME Type 嗅探
  res.setHeader('X-Content-Type-Options', 'nosniff');

  // 防止 Clickjacking
  res.setHeader('X-Frame-Options', 'DENY');

  // 強制 HTTPS
  res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');

  next();
});

飛飛觀點:
安全編碼不是某一個人的責任,而是整個團隊的文化。當團隊有共用的安全工具、有 Code Review 的安全檢查項目、有自動化的靜態分析,安全編碼就不再是「額外的工作」,而是開發流程的自然一環。就像工地的安全帽——不是因為你膽小才戴,而是因為這是專業的基本。


七、常見問題 FAQ

Q1:輸入驗證應該在哪一層做?Controller?Service?還是 Middleware?

簡短回答:多層都做,各司其職。

  • Middleware / Controller 層:做格式驗證(型別、長度、格式),用 express-validator 之類的工具
  • Service / 業務邏輯層:做業務規則驗證(這個使用者有沒有購買過?庫存夠不夠?)
  • Database 層:用資料庫的 constraint(NOT NULL、UNIQUE、CHECK)作為最後一道防線

每一層的驗證目的不同。Middleware 擋住明顯的惡意輸入,Service 確保業務邏輯正確,Database 防止資料不一致。

Q2:輸出編碼應該在存入資料庫前做,還是輸出時再做?

答案是:輸出時再做。

存入資料庫時,盡量保留原始資料。原因有兩個:一是你不知道這筆資料未來會輸出到什麼環境(HTML?Email?PDF?手機 App?),不同環境需要不同的編碼方式;二是如果你在存入時就編碼了,日後要搜尋、排序、分析這些資料會很困難。

唯一的例外是:如果你確定這筆資料永遠只會在特定環境使用,且存入前做清理有明確的安全需求(例如用 DOMPurify 移除惡意 HTML),那可以在存入時先做一次清理。

Q3:我用了 React / Vue 這類前端框架,還需要擔心 XSS 嗎?

React 和 Vue 確實會在預設情況下對輸出做 HTML 編碼,這大幅降低了 XSS 的風險。但有幾個陷阱要注意:

  • React 的 <code>dangerouslySetInnerHTML</code> 不會做編碼(名字裡都有 dangerous 了)
  • Vue 的 <code>v-html</code> 不會做編碼
  • 將資料放入 <code>href</code>、<code>src</code> 等屬性時,<code>javascript:</code> 偽協定仍然危險
  • Server-Side Rendering(SSR)時的處理方式可能不同

所以,前端框架幫你擋了大部分,但不代表你可以完全不管。特別是後端 API 回傳的資料,不要假設前端一定會正確處理。

Q4:Vibe Coding 時代,AI 生成的程式碼安全嗎?

不一定安全。 AI 生成的程式碼經常會出現安全問題,特別是:

  • 直接用字串拼接 SQL(AI 為了「簡單」常這樣做)
  • API 回傳完整的資料庫物件(包含密碼欄位)
  • 缺少輸入驗證(AI 可能只生成「happy path」的程式碼)
  • 使用已知有漏洞的套件版本

建議:AI 生成的每一段程式碼,都要用本文的 Checklist 檢查一遍。把 AI 當成一個「很快但粗心的實習生」——它寫得很快,但你要負責審查。


八、結語:安全編碼是手藝,不是負擔

回到蓋房子的比喻。

沒有人會說:「用防火建材好麻煩,我們直接用保麗龍隔間比較快。」因為每個人都知道,建材不合格,房子蓋再快也沒用——一把火就全沒了。

安全編碼也是一樣。輸入驗證和輸出編碼看起來是「多做的工作」,但它們其實是在保護你已經投入的所有心血。你花了幾個月開發的功能,不應該因為一個 SQL Injection 就全部白費。

而且,安全編碼做得越多,你會發現一件事:它會讓你的程式碼品質變好。 當你養成驗證輸入的習慣,你自然會把邊界條件想得更清楚;當你養成輸出編碼的習慣,你自然會更理解不同環境的差異。

這些都是好工程師的基本功。

安全編碼不是給程式碼加鎖鏈,而是給程式碼穿上工作服——
它不會讓你動作變慢,反而會讓你更專業、更可靠。

下一篇,我們將繼續探索安全實作階段的進階主題——OWASP Top 10 防禦實戰指南。當你掌握了輸入驗證與輸出編碼的基本功,就可以進一步了解更多攻擊手法與對應的防禦策略。


延伸閱讀