セキュリティテスト — 脆弱性を自動で発見する
「次のインシデントが起きる前に、自分で脆弱性を見つけたい」
リョウは3ヶ月間のセキュリティ強化作業を終えて、ようやくそう思えるようになっていた。しかし問題は、新しいコードを書くたびに新しい脆弱性が生まれる可能性があることだ。
「手動レビューだけでは限界がある。自動化が必要だ」
セキュリティテストのレイヤー
Loading diagram...
Brakemanで静的解析
BrakemanはRailsアプリ専用の静的セキュリティ解析ツールだ。コードを実行せずに脆弱なパターンを検出する。
# インストール
gem install brakeman
# プロジェクトをスキャン
brakeman --format json --output brakeman-report.json
# 特定の警告のみ表示
brakeman --only-files app/ --format table
# CI/CDで使用(警告があれば終了コード1)
brakeman --exit-on-warnBrakemanが検出できる脆弱性:
# 検出例1: SQLインジェクション(警告を出すコード)
# brakeman: SQL injection possible
User.where("email = '#{params[:email]}'")
# 検出例2: コマンドインジェクション
# brakeman: Possible command injection
system("ls #{params[:dir]}")
# 検出例3: マスアサインメント
# brakeman: Unprotected mass assignment
User.update_all(params[:user])
# 検出例4: ハードコードされたシークレット
# brakeman: Hard-coded secret in source
API_KEY = "sk-1234567890abcdef"# .brakeman.yml(設定ファイル)
---
rails_version: "7.1"
min_confidence: 2 # 信頼度2以上の警告のみ
ignore_model_output: true
output_files:
- brakeman-report.json
- brakeman-report.htmlRSpecでセキュリティテスト
単体テストでセキュリティ要件を明示的にテストする。
# spec/security/authentication_spec.rb
RSpec.describe "Authentication Security", type: :request do
describe "JWT Security" do
it "rejects tokens with alg: none" do
# alg:noneの偽造トークン
header = Base64.urlsafe_encode64('{"alg":"none","typ":"JWT"}', padding: false)
payload = Base64.urlsafe_encode64({ sub: 1, exp: 1.hour.from_now.to_i }.to_json, padding: false)
malicious_token = "#{header}.#{payload}."
get "/api/v1/inventory",
headers: { "Authorization" => "Bearer #{malicious_token}" }
expect(response.status).to eq(401)
end
it "rejects expired tokens" do
expired_token = JsonWebToken.encode({ sub: 1 }, exp: 1.hour.ago)
get "/api/v1/inventory",
headers: { "Authorization" => "Bearer #{expired_token}" }
expect(response.status).to eq(401)
expect(json_response['error']).to include('expired')
end
it "rejects tokens with tampered payload" do
token = JsonWebToken.encode({ sub: 1, role: 'user' })
# ペイロード部分を改ざん
parts = token.split('.')
tampered_payload = Base64.urlsafe_encode64(
{ sub: 1, role: 'admin' }.to_json,
padding: false
)
tampered_token = [parts[0], tampered_payload, parts[2]].join('.')
get "/api/v1/admin",
headers: { "Authorization" => "Bearer #{tampered_token}" }
expect(response.status).to eq(401)
end
end
describe "SQL Injection Prevention" do
it "safely handles SQL injection attempts in search" do
user = create(:user)
token = JsonWebToken.encode({ sub: user.id })
malicious_inputs = [
"'; DROP TABLE users; --",
"1' OR '1'='1",
"1; SELECT * FROM users--",
"\" OR 1=1--"
]
malicious_inputs.each do |input|
get "/api/v1/inventory",
params: { name: input },
headers: { "Authorization" => "Bearer #{token}" }
# 正常なレスポンス(200または空の結果)
expect(response.status).to be_in([200, 404])
# テーブルが削除されていないことを確認
expect(User.count).to be > 0
end
end
end
describe "Rate Limiting" do
it "blocks excessive login attempts" do
6.times do
post "/api/v1/auth/login",
params: { email: "user@example.com", password: "wrongpassword" },
as: :json
end
expect(response.status).to eq(429)
expect(response.headers['Retry-After']).to be_present
end
end
describe "CORS" do
it "rejects requests from unauthorized origins" do
get "/api/v1/inventory",
headers: { "Origin" => "https://evil.example.com" }
expect(response.headers['Access-Control-Allow-Origin']).to be_nil
end
end
describe "Authorization" do
it "prevents users from accessing other users data" do
user1 = create(:user)
user2 = create(:user)
item = create(:inventory_item, company: user2.company)
token = JsonWebToken.encode({ sub: user1.id })
get "/api/v1/inventory/#{item.id}",
headers: { "Authorization" => "Bearer #{token}" }
expect(response.status).to eq(404) # 他のユーザーのデータは見えない
end
it "prevents parameter tampering for company_id" do
user = create(:user)
other_company = create(:company)
token = JsonWebToken.encode({ sub: user.id })
post "/api/v1/inventory",
params: {
inventory_item: {
name: "Item",
company_id: other_company.id # 別会社のIDを指定
}
},
headers: { "Authorization" => "Bearer #{token}" },
as: :json
# 自分の会社にのみ作成される
item = InventoryItem.last
expect(item.company_id).to eq(user.company_id)
expect(item.company_id).not_to eq(other_company.id)
end
end
endOWASP ZAPによる動的スキャン
OWASP ZAP(Zed Attack Proxy)は実際のHTTPリクエストを送ってAPIをスキャンする動的テストツールだ。
# .github/workflows/security.yml
name: Security Testing
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
sast:
name: Static Analysis (Brakeman)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Brakeman
run: |
gem install brakeman
brakeman --format json --output brakeman-report.json --exit-on-warn
continue-on-error: false # 警告があればCI失敗
- name: Upload Brakeman Report
uses: actions/upload-artifact@v4
if: always()
with:
name: brakeman-report
path: brakeman-report.json
dast:
name: Dynamic Analysis (OWASP ZAP)
runs-on: ubuntu-latest
services:
app:
image: stockflow/api:latest
ports:
- 3000:3000
env:
RAILS_ENV: test
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
steps:
- uses: actions/checkout@v4
- name: ZAP API Scan
uses: zaproxy/action-api-scan@v0.7.0
with:
target: 'http://localhost:3000'
format: openapi
api_scan_rules_file_name: zap-rules.conf
fail_action: true
cmd_options: '-config scanner.maxScanDurationInMins=10'
- name: Upload ZAP Report
uses: actions/upload-artifact@v4
if: always()
with:
name: zap-report
path: report_json.json# zap-rules.conf(ZAPルール設定)
# 40012 = Cross Site Scripting (Reflected) - WARN
# 40018 = SQL Injection - FAIL
# 90019 = Server Side Code Injection - FAIL
# 10010 = Cookie No HttpOnly Flag - WARN
40012 = WARN
40018 = FAIL
90019 = FAIL
10010 = WARN
10016 = WARN # Web Browser XSS Protection Not EnabledDependabotで依存関係の脆弱性を監視
# .github/dependabot.yml
version: 2
updates:
# Gemの依存関係
- package-ecosystem: "bundler"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Tokyo"
labels:
- "dependencies"
- "security"
open-pull-requests-limit: 10
# セキュリティアップデートは自動マージ
auto-merged-rules:
- match:
dependency-type: "indirect"
update-type: "security:minor"
# npmパッケージ
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "javascript"
ignore:
- dependency-name: "*"
update-type: "version-update:semver-major" # メジャーバージョンアップは手動
# Dockerイメージ
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"bundle-auditで既知の脆弱性をチェック
# インストール
gem install bundler-audit
# データベースを更新
bundle-audit update
# スキャン実行
bundle-audit check
# アドバイザリIDを無視する場合(誤検知やパッチ適用済みの場合)
bundle-audit check --ignore CVE-2024-XXXX# .github/workflows/security.yml に追加
dependency-audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Bundle Audit
run: |
gem install bundler-audit
bundle-audit update
bundle-audit check --format json | tee bundle-audit-report.json
- name: Parse Audit Results
if: failure()
run: |
echo "### Security Vulnerabilities Found" >> $GITHUB_STEP_SUMMARY
cat bundle-audit-report.json | jq '.results[] | "- \(.advisory.id): \(.advisory.title) (\(.gem.name) \(.gem.version))"' -r >> $GITHUB_STEP_SUMMARYセキュリティレビューチェックリストの自動化
# lib/tasks/security_check.rake
namespace :security do
desc "Run all security checks"
task check: :environment do
errors = []
# 1. 環境変数チェック
required_env_vars = %w[
JWT_SECRET KMS_KEY_ARN REDIS_URL
COGNITO_USER_POOL_ID AWS_REGION
]
required_env_vars.each do |var|
errors << "Missing required environment variable: #{var}" unless ENV[var].present?
end
# 2. SSL設定チェック
unless Rails.application.config.force_ssl
errors << "HTTPS is not enforced (force_ssl is false)"
end
# 3. セッションの設定チェック
session_store = Rails.application.config.session_store
unless session_store == :redis_store
errors << "Insecure session store: #{session_store}"
end
# 4. Cookieの設定チェック
unless Rails.application.config.action_dispatch.cookies_same_site_protection == :strict
errors << "SameSite cookie protection is not set to :strict"
end
if errors.any?
puts "Security check FAILED:"
errors.each { |e| puts " - #{e}" }
exit 1
else
puts "Security check PASSED"
end
end
endAmazon GuardDutyで本番監視
# GuardDutyを有効化
aws guardduty create-detector \
--enable \
--finding-publishing-frequency SIX_HOURS
# EKS/ECSランタイム保護を有効化
aws guardduty update-detector \
--detector-id <detector-id> \
--features '[{"Name":"RUNTIME_MONITORING","Status":"ENABLED"}]'GuardDutyが検出できる脅威:
- EC2インスタンスへの不正アクセス
- 異常なAPI呼び出しパターン
- マルウェアの検出
- 認証情報の不正使用
- データ流出の試み
# GuardDutyアラートの処理
# app/jobs/guard_duty_alert_job.rb
class GuardDutyAlertJob < ApplicationJob
def perform(finding)
severity = finding['Severity']
finding_type = finding['Type']
case severity
when 7.0..10.0 # HIGH
# 即座にSlackとPagerDutyに通知
SlackNotifier.alert(
channel: '#security-critical',
message: "HIGH severity GuardDuty finding: #{finding_type}",
details: finding
)
PagerDutyClient.trigger_incident(finding)
when 4.0..6.9 # MEDIUM
# Slackに通知
SlackNotifier.notify('#security-alerts', finding)
else # LOW
# ログに記録のみ
Rails.logger.info("GuardDuty LOW severity: #{finding_type}")
end
end
endペネトレーションテストの実施
定期的な外部ペネトレーションテストも重要だ。
## ペネトレーションテスト計画書(テンプレート)
### スコープ
- 対象: https://api.stockflow.example.com
- 期間: 2024-02-01 〜 2024-02-15
- 方法: ブラックボックステスト
### テスト項目
1. 認証バイパスの試み
2. 認可の迂回
3. インジェクション攻撃(SQL、NoSQL、コマンド)
4. セッション管理の問題
5. レート制限の有効性
6. ビジネスロジックの欠陥
7. APIドキュメントからの情報収集
### 報告形式
- 発見した脆弱性のCVSSスコア評価
- 実証可能なPoC(概念実証)
- 修正推奨事項
- 修正後の再テストINFO
ペネトレーションテストは専門の外部セキュリティ業者に依頼することが推奨されます。内部チームでは発見できない「思い込み」の死角を、外部の目で発見できます。重要な機能リリース前や年次で実施するのが一般的です。
チェックリスト
- BrakemanをCI/CDパイプラインに組み込んでいる(PRごとに実行)
- bundle-auditで依存関係の脆弱性を定期チェックしている
- Dependabotでセキュリティアップデートを自動化している
- OWASP ZAPによる動的スキャンをCI/CDに組み込んでいる
- 認証、認可、インジェクションのセキュリティテストを書いている
- Amazon GuardDutyを本番環境で有効化している
- セキュリティアラートの通知先と対応手順を定めている
- 年1回以上のペネトレーションテストを実施している
- 発見した脆弱性の管理・追跡プロセスが確立されている