mybook

セキュリティテスト — 脆弱性を自動で発見する

「次のインシデントが起きる前に、自分で脆弱性を見つけたい」

リョウは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-warn

Brakemanが検出できる脆弱性:

# 検出例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.html

RSpecでセキュリティテスト

単体テストでセキュリティ要件を明示的にテストする。

# 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
end

OWASP 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 Enabled

Dependabotで依存関係の脆弱性を監視

# .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
end

Amazon 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回以上のペネトレーションテストを実施している
  • 発見した脆弱性の管理・追跡プロセスが確立されている