[安全教育] 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 個成熟度等級。你可以用它來:
- 評估現況:我們現在在哪裡?
- 設定目標:我們想達到什麼水準?
- 追蹤進步:我們有沒有在進步?
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 個指標——那只會讓大家疲於奔命。先從這三個最基本的開始:
- 高風險漏洞數量(KRI)— 知道你現在有多危險
- 高風險漏洞 MTTR(KPI)— 知道你修得夠不夠快
- 安全掃描覆蓋率(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 的七個階段,我們一路從安全需求走到了資安成效追蹤。這不是終點,而是一個新的循環的起點。有了度量,你才知道下一輪要從哪裡開始改進;有了數據,你才能持續讓安全變得更好。
記住,沒有度量的安全是盲目的努力,而有度量的安全是持續進化的力量。
讓我們用數據,讓安全的價值被每一個人看見。
延伸閱讀
- OWASP SAMM — Software Assurance Maturity Model — 評估團隊安全開發成熟度的框架
- NIST SP 800-55 — Performance Measurement Guide for Information Security — 資安績效衡量指南
- IBM Cost of a Data Breach Report 2024 — 資料外洩成本年度報告
- OWASP DevSecOps Metrics — DevSecOps 度量指南
- CIS Risk Assessment Method (RAM) — CIS 風險評估方法
- SSDLC by 飛飛 — 學習路徑
- SSDLC — 什麼是 SSDLC?讓安全融入開發的每個階段
- SSDLC — 安全監控與告警:建立你的資安儀表板
- SSDLC — CI/CD 安全整合:在 Pipeline 加入安全關卡
- SSDLC — 事件回應 SOP 完整指南
[安全教育] 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、每一次部署中,自然而然發生的事。
延伸閱讀
- OWASP Security Champions Guide — OWASP 官方的 Security Champion 計畫指南
- OWASP Security Champions Playbook — 實戰手冊
- Security Champion Program Success Guide — 完整的成功計畫指南
- AWS Security Champion Knowledge Path — AWS 提供的 Security Champion 學習路徑
- OWASP SAMM — Security Assurance Maturity Model — 安全成熟度評估框架
- BSIMM — Building Security In Maturity Model — 軟體安全成熟度模型
- 系列文章:什麼是 SSDLC?讓安全融入開發的每個階段
- 系列文章:威脅建模入門:用 STRIDE 找出系統弱點
- 系列文章:SAST 靜態分析入門:讓工具幫你審程式碼
- 系列文章:CI/CD 安全整合:在 Pipeline 加入安全關卡
[安全維運] 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 分鐘跟你的團隊坐下來,回答這三個問題:
- 如果我們的系統現在被入侵了,第一個被通知的人是誰?
- 他/她知道接下來該做什麼嗎?
- 我們有沒有一份寫下來的流程可以參考?
如果有任何一個答案是「不知道」或「沒有」,那就是你開始建立事件回應 SOP 的最好時機。
安全不是恐懼,而是創造的基礎。 一份好的事件回應計畫,讓你的團隊在面對資安事件時,從「恐慌」變成「有序處理」——這就是專業。
延伸閱讀
官方資源:
- NIST SP 800-61 Rev.2:Computer Security Incident Handling Guide
- NIST CSF 2.0 — RESPOND & RECOVER 功能
- OWASP Incident Response Cheat Sheet
台灣法規:
系列文章:
[安全維運] 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 應用來說,這三個最關鍵:
- 登入失敗率 — 偵測帳號被盜或暴力破解
- 403/401 錯誤趨勢 — 偵測越權存取嘗試
- 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——當告警真的響起來了,你和你的團隊該怎麼辦?別擔心,有了監控的基礎,你已經贏在起跑點了。
延伸閱讀
- OWASP Logging Cheat Sheet
- NIST SP 800-92 Guide to Computer Security Log Management
- OWASP Application Security Verification Standard (ASVS) — V7: Error Handling and Logging
- Grafana Loki Documentation
- Wazuh — Open Source Security Platform
- SSDLC by 飛飛 — 學習路徑
- SSDLC — 安全需求:台灣法規遵循指南
- SSDLC — CI/CD 安全整合:在 Pipeline 加入安全關卡
[安全部署] 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 官網註冊帳號即可:
- 前往 https://www.cisecurity.org/cis-benchmarks
- 選擇你需要的技術類別(如 Linux、Docker、AWS)
- 填寫基本資訊後即可下載 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 幾百頁的文件嚇到。先做這三件事,就能大幅降低風險:
- 更新系統和套件:把已知的漏洞修掉
- 關閉不必要的 Port 和服務:減少攻擊面
- 不用 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 的旅程中,我們從安全需求出發,經過安全設計、安全編碼、安全測試,現在來到了部署與維運。你的程式碼已經足夠安全了,現在讓它跑在同樣安全的環境上吧。
下一篇,我們會繼續深入部署與維運階段的另一個重要主題——安全監控與告警:建立你的資安儀表板。系統上線後,怎麼知道有沒有人在敲你的門窗?怎麼第一時間發現異常?我們下次見。
延伸閱讀
- CIS Benchmarks 官方下載 — 免費下載各平台的安全設定建議
- Docker Security Best Practices — OWASP — Docker 安全速查表
- Trivy — Container Security Scanner — 開源容器漏洞掃描工具
- Lynis — Security Auditing Tool — Linux 系統安全審計工具
- AWS Well-Architected Framework — Security Pillar — AWS 安全架構最佳實踐
- NIST SP 800-123 — Guide to General Server Security — 伺服器安全通用指南
- CI/CD 安全整合:在 Pipeline 加入安全關卡 — SSDLC by 飛飛系列文章
- 安全設計原則實戰指南:最小權限、縱深防禦、預設安全 — SSDLC by 飛飛系列文章
[安全測試] 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 標籤中:
- 輸入目標 URL:<code>http://localhost:3000</code>
- 點擊「Attack」
- 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 不是完美的,它有時候會報告「假警報」。處理誤報的步驟:
- 手動驗證:對 ZAP 標記的漏洞,嘗試手動重現攻擊
- 分析上下文:看看這個 Alert 在你的系統中是否真的構成風險
- 標記為誤報:在 ZAP 中將確認的誤報標記為「False Positive」,避免下次重複出現
- 記錄原因:記下為什麼判定為誤報,方便團隊日後參考
產出掃描報告:給團隊和主管看的安全報告
從 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 工具會一次全部標出來:
- 硬編碼密碼(<code>DB_PASSWORD</code> 直接寫在程式碼裡)
- SQL Injection(字串拼接組合 SQL 查詢)
- 錯誤資訊洩露(把資料庫錯誤訊息直接回傳給使用者)
- 過度資料暴露(回傳整個 <code>results</code> 物件,可能包含敏感欄位)
這就是 SAST 的價值——它不會累、不會忘記、不會因為趕 deadline 就放水。
三、工具選擇:SonarQube vs. Semgrep
市面上 SAST 工具百百種,但對於台灣的中小型開發團隊來說,有兩個工具特別值得認識:SonarQube 和 Semgrep。它們各有特色,適合不同的使用情境。
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 加密 | 法規要求可查詢 |
| <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 裡。
九、結語:加密是手藝,不是魔法
回到蓋房子的比喻。
保險箱再堅固,鑰匙管理不好也白搭。加密演算法再強大,金鑰硬寫在程式碼裡也沒用。密碼雜湊再安全,不加鹽就形同虛設。
密碼與機敏資料處理的核心不是「用了什麼演算法」,而是「有沒有在每一個環節都做對」。從資料的產生、傳輸、存儲、到顯示,每一步都需要正確的保護措施。就像蓋房子的保險箱,不只是買一個好保險箱就夠了——你還要把它固定在牆裡、保管好鑰匙、定期更換密碼、只讓該看的人看。
安全不是一次性的動作,而是持續的紀律。
飛飛觀點:
記住,完美的加密機制不存在,但「夠好的加密」加上「正確的管理」,可以讓攻擊者的成本高到不值得攻擊你。安全不是恐懼,而是創造的基礎——當你知道使用者的資料被好好保護,你才能安心地創造更好的產品。
十、延伸閱讀
官方資源
- OWASP Password Storage Cheat Sheet:密碼儲存的完整指南
- OWASP Cryptographic Storage Cheat Sheet:加密儲存最佳實踐
- OWASP Key Management Cheat Sheet:金鑰管理指南
- NIST SP 800-63B:數位身分認證指南
- NIST SP 800-132:密碼基礎金鑰衍生建議
工具推薦
| 工具 | 用途 | 說明 |
|---|---|---|
| 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 管理是否正確?有速率限制嗎?
建議二:從風險最高的三項開始
不要試圖一次做完十項。根據數據,先搞定前三名就能大幅降低風險:
- A01 Broken Access Control(影響最廣)
- A02 Security Misconfiguration(最容易發生)
- 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)——真正動手施工的階段。
想像你是一個建築工人。建築師已經畫好了完美的藍圖,結構技師也算好了承重。但如果你施工時用了劣質的電線、沒有接地線的插座、不防火的隔間材料——那再好的設計也白搭。一場電線走火,整棟房子就毀了。
安全編碼就是軟體開發中的「使用合格建材」。 不管你的需求寫得多完整、設計做得多周全,如果程式碼本身有漏洞,攻擊者照樣長驅直入。
而在所有安全編碼的原則中,有兩條是最基礎、最重要、卻也最常被忽略的:
- 輸入驗證(Input Validation)——永不信任任何來自外部的資料
- 輸出編碼(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 流程中,加入以下安全問題:
- 這段程式碼有接受外部輸入嗎?有做驗證嗎?
- 有使用字串拼接組合 SQL 嗎?
- 使用者資料有被輸出到 HTML/JS/URL 嗎?有做編碼嗎?
- 錯誤處理有洩露系統內部資訊嗎?
建議四:使用 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 防禦實戰指南。當你掌握了輸入驗證與輸出編碼的基本功,就可以進一步了解更多攻擊手法與對應的防禦策略。