mybook

ロギングと監査 — 何が起きたかを追跡する

「攻撃者が何をしたか、どこまで侵入したか、正確にわかりますか?」

弁護士からのメールが届いたのは、インシデントから2週間後だった。顧客の一社が法的措置を検討しているという。

リョウはログを見返した。アプリケーションログはあった。しかし、それは断片的で、誰がいつ何のデータにアクセスしたかを正確に追跡できなかった。

「ログが足りない。これでは何も証明できない」

セキュリティロギングの原則

良いセキュリティログには4つの要素が必要だ。

Loading diagram...

構造化ログの実装

テキストログではなく、JSONベースの構造化ログを使う。機械処理しやすく、検索・集計が容易だ。

# Gemfile
gem 'lograge'         # Railsのログを構造化
gem 'logstash-event'  # JSON形式のログ
# config/initializers/lograge.rb
Rails.application.configure do
  config.lograge.enabled = true
  config.lograge.formatter = Lograge::Formatters::Json.new
 
  config.lograge.custom_options = lambda do |event|
    {
      request_id: event.payload[:request_id] || SecureRandom.uuid,
      user_id: event.payload[:user_id],
      company_id: event.payload[:company_id],
      ip: event.payload[:remote_ip],
      user_agent: event.payload[:user_agent],
      params: sanitize_params(event.payload[:params]),
      duration: event.duration.round(2)
    }.compact
  end
 
  def sanitize_params(params)
    return {} unless params
    # 機密情報をログから除外
    params.except('password', 'password_confirmation', 'token', 'secret', 'credit_card')
  end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  before_action :set_request_context
 
  private
 
  def set_request_context
    # リクエストIDをスレッドローカルに設定(全ログで共有)
    Thread.current[:request_id] = request.request_id || SecureRandom.uuid
    Thread.current[:user_id] = current_user&.id
    Thread.current[:company_id] = current_user&.company_id
 
    # Lograge用のペイロードを設定
    append_info_to_payload({
      request_id: Thread.current[:request_id],
      user_id: Thread.current[:user_id],
      company_id: Thread.current[:company_id],
      remote_ip: real_ip,
      user_agent: request.user_agent
    })
  end
 
  def real_ip
    # CloudFront/ALBを通じた場合の本物のIPを取得
    request.env['HTTP_X_FORWARDED_FOR']&.split(',')&.first&.strip ||
      request.env['REMOTE_ADDR']
  end
end

監査ログの実装

誰がいつ何のデータを変更したかを完全に記録する。

# db/migrate/20240115000005_create_audit_logs.rb
class CreateAuditLogs < ActiveRecord::Migration[7.1]
  def change
    create_table :audit_logs do |t|
      t.string :event_type, null: false           # 'read', 'create', 'update', 'delete'
      t.string :resource_type, null: false         # 'InventoryItem', 'Customer', etc.
      t.bigint :resource_id                        # 操作対象のID
      t.references :user, foreign_key: true         # 操作したユーザー
      t.string :ip_address
      t.string :user_agent
      t.string :request_id
      t.jsonb :changes, default: {}               # 変更前後の値
      t.jsonb :metadata, default: {}              # 追加情報
      t.datetime :occurred_at, null: false, default: -> { 'CURRENT_TIMESTAMP' }
    end
 
    # 検索・分析用インデックス
    add_index :audit_logs, :event_type
    add_index :audit_logs, [:resource_type, :resource_id]
    add_index :audit_logs, :user_id
    add_index :audit_logs, :occurred_at
    add_index :audit_logs, :ip_address
    add_index :audit_logs, :request_id
 
    # 過去データの効率的な管理(パーティション用)
    add_index :audit_logs, [:occurred_at, :event_type]
  end
end
# app/models/concerns/auditable.rb
module Auditable
  extend ActiveSupport::Concern
 
  included do
    after_create  :log_create
    after_update  :log_update
    after_destroy :log_destroy
  end
 
  # 読み取りイベントを明示的に記録(after_findは負荷が高すぎるため手動)
  def log_read(user: nil, metadata: {})
    AuditLog.create!(
      event_type: 'read',
      resource_type: self.class.name,
      resource_id: id,
      user: user || current_audit_user,
      ip_address: current_ip,
      request_id: Thread.current[:request_id],
      metadata: metadata,
      occurred_at: Time.current
    )
  end
 
  private
 
  def log_create
    AuditLog.create!(
      event_type: 'create',
      resource_type: self.class.name,
      resource_id: id,
      user_id: Thread.current[:user_id],
      ip_address: Thread.current[:ip_address],
      request_id: Thread.current[:request_id],
      changes: { after: sanitized_attributes },
      occurred_at: Time.current
    )
  end
 
  def log_update
    return if saved_changes.empty?
 
    AuditLog.create!(
      event_type: 'update',
      resource_type: self.class.name,
      resource_id: id,
      user_id: Thread.current[:user_id],
      ip_address: Thread.current[:ip_address],
      request_id: Thread.current[:request_id],
      changes: sanitized_changes,
      occurred_at: Time.current
    )
  end
 
  def log_destroy
    AuditLog.create!(
      event_type: 'delete',
      resource_type: self.class.name,
      resource_id: id,
      user_id: Thread.current[:user_id],
      ip_address: Thread.current[:ip_address],
      request_id: Thread.current[:request_id],
      changes: { before: sanitized_attributes },
      occurred_at: Time.current
    )
  end
 
  def sanitized_attributes
    # 機密情報を監査ログから除外
    attributes.except('password_digest', 'encrypted_fields')
  end
 
  def sanitized_changes
    # 変更前後の値から機密情報を除外
    sensitive_fields = %w[password_digest encrypted_purchase_price]
    saved_changes.except(*sensitive_fields).transform_values do |change|
      change.map { |v| v.is_a?(String) && v.length > 100 ? "#{v.first(50)}...[truncated]" : v }
    end
  end
end
# app/models/inventory_item.rb
class InventoryItem < ApplicationRecord
  include Auditable
 
  # 機密フィールドは変更ログから除外
  SENSITIVE_FIELDS = %w[encrypted_purchase_price encrypted_supplier_code].freeze
end

セキュリティイベントの記録

認証関連のセキュリティイベントを専用に記録する。

# app/services/security_event_logger.rb
class SecurityEventLogger
  EVENTS = {
    login_success: { severity: 'INFO', alert: false },
    login_failure: { severity: 'WARNING', alert: false },
    login_locked: { severity: 'WARNING', alert: true },
    logout: { severity: 'INFO', alert: false },
    password_changed: { severity: 'INFO', alert: true },
    mfa_enabled: { severity: 'INFO', alert: true },
    mfa_disabled: { severity: 'WARNING', alert: true },
    api_key_created: { severity: 'INFO', alert: false },
    api_key_revoked: { severity: 'INFO', alert: false },
    suspicious_activity: { severity: 'CRITICAL', alert: true },
    data_export: { severity: 'WARNING', alert: true },
    admin_access: { severity: 'WARNING', alert: false },
    permission_denied: { severity: 'WARNING', alert: false }
  }.freeze
 
  def self.log(event_type, user: nil, metadata: {}, request: nil)
    event_config = EVENTS[event_type.to_sym]
    raise ArgumentError, "Unknown security event: #{event_type}" unless event_config
 
    log_entry = {
      timestamp: Time.current.utc.iso8601,
      event_type: event_type.to_s,
      severity: event_config[:severity],
      user_id: user&.id,
      user_email: user&.email,
      ip_address: request&.remote_ip || Thread.current[:ip_address],
      user_agent: request&.user_agent,
      request_id: Thread.current[:request_id],
      metadata: metadata
    }
 
    # 構造化ログとして出力
    Rails.logger.tagged("SECURITY") do
      Rails.logger.send(event_config[:severity].downcase, log_entry.to_json)
    end
 
    # CloudWatch Logsにも送信(非同期)
    CloudWatchLogJob.perform_later('security-events', log_entry) if event_config[:alert]
 
    # 重要イベントは即座にアラート
    SecurityAlertJob.perform_later(event_type, log_entry) if event_config[:alert]
  end
end
# app/controllers/api/v1/sessions_controller.rb
def create
  user = User.find_by(email: params[:email])
  result = user&.authenticate_with_lockout(params[:password])
 
  if result&.dig(:success)
    SecurityEventLogger.log(:login_success, user: user, request: request)
    render json: { token: generate_token(user) }
  else
    SecurityEventLogger.log(:login_failure,
      user: user,
      request: request,
      metadata: { email: params[:email] }
    )
    render json: { error: "Invalid credentials" }, status: :unauthorized
  end
end

CloudWatch Logsへの集約

# app/jobs/cloud_watch_log_job.rb
class CloudWatchLogJob < ApplicationJob
  queue_as :high_priority
 
  LOG_GROUP = '/stockflow/security-events'
 
  def perform(stream_name, log_entry)
    client = Aws::CloudWatchLogs::Client.new
 
    # ログストリームの作成(存在する場合は無視)
    begin
      client.create_log_stream(
        log_group_name: LOG_GROUP,
        log_stream_name: "#{stream_name}/#{Date.current}"
      )
    rescue Aws::CloudWatchLogs::Errors::ResourceAlreadyExistsException
      # 既存のストリームは再利用
    end
 
    client.put_log_events(
      log_group_name: LOG_GROUP,
      log_stream_name: "#{stream_name}/#{Date.current}",
      log_events: [{
        timestamp: (log_entry[:timestamp].to_time.to_f * 1000).to_i,
        message: log_entry.to_json
      }]
    )
  end
end

AWS CloudTrailでインフラ操作を追跡

# CloudTrailの設定
aws cloudtrail create-trail \
  --name stockflow-audit-trail \
  --s3-bucket-name stockflow-cloudtrail-logs \
  --include-global-service-events \
  --is-multi-region-trail \
  --enable-log-file-validation
 
# S3データイベントの記録(誰がS3にアクセスしたか)
aws cloudtrail put-event-selectors \
  --trail-name stockflow-audit-trail \
  --event-selectors '[{
    "ReadWriteType": "All",
    "IncludeManagementEvents": true,
    "DataResources": [{
      "Type": "AWS::S3::Object",
      "Values": ["arn:aws:s3:::stockflow-exports/"]
    }]
  }]'

ログの改ざん防止

監査ログは証拠として機能するため、改ざんを防ぐ必要がある。

# app/models/audit_log.rb
class AuditLog < ApplicationRecord
  # 作成のみ可能(更新・削除を禁止)
  before_update { raise ActiveRecord::ReadOnlyRecord }
  before_destroy { raise ActiveRecord::ReadOnlyRecord }
 
  # ログのハッシュチェーン(前のログのハッシュを含む)
  before_create :compute_integrity_hash
 
  private
 
  def compute_integrity_hash
    previous = AuditLog.order(:id).last
    content = {
      event_type: event_type,
      resource_type: resource_type,
      resource_id: resource_id,
      user_id: user_id,
      changes: changes,
      occurred_at: occurred_at.iso8601,
      previous_hash: previous&.integrity_hash
    }.to_json
 
    self.integrity_hash = OpenSSL::HMAC.hexdigest(
      'SHA256',
      Rails.application.credentials.audit_hmac_key!,
      content
    )
  end
end
# S3への定期バックアップ(改ざん防止のため別システムに保存)
# app/jobs/backup_audit_logs_job.rb
class BackupAuditLogsJob < ApplicationJob
  def perform(date = Date.yesterday)
    logs = AuditLog.where(occurred_at: date.all_day).to_json
 
    # S3のオブジェクトロック(WORM)を使った改ざん防止
    s3 = Aws::S3::Client.new
    key = "audit-logs/#{date.strftime('%Y/%m/%d')}.json.gz"
 
    compressed = Zlib::Deflate.deflate(logs)
 
    s3.put_object(
      bucket: ENV['AUDIT_LOG_BUCKET'],
      key: key,
      body: compressed,
      content_encoding: 'gzip',
      content_type: 'application/json',
      object_lock_mode: 'COMPLIANCE',
      object_lock_retain_until_date: 7.years.from_now  # 法的保存期間
    )
  end
end

INFO

S3のオブジェクトロック(WORM: Write Once Read Many)を有効にすると、保存期間中はファイルの削除・上書きができません。コンプライアンス要件で監査ログを7年間保存する場合に有効です。

リアルタイム異常検知

# app/services/anomaly_detector.rb
class AnomalyDetector
  THRESHOLDS = {
    login_failures_per_minute: 10,
    api_requests_per_minute: 500,
    data_export_per_hour: 3,
    permission_denials_per_minute: 20
  }.freeze
 
  def self.check(event_type, user_id: nil, ip:)
    case event_type.to_sym
    when :login_failure
      count = increment_counter("login_failures:#{ip}", 60)
      alert_if_threshold(:login_failures_per_minute, count, { ip: ip })
 
    when :api_request
      count = increment_counter("api_requests:#{ip}", 60)
      alert_if_threshold(:api_requests_per_minute, count, { ip: ip })
 
    when :data_export
      count = increment_counter("data_export:#{user_id}", 3600)
      alert_if_threshold(:data_export_per_hour, count, { user_id: user_id })
    end
  end
 
  private
 
  def self.increment_counter(key, ttl)
    Rails.cache.increment("anomaly:#{key}", 1, expires_in: ttl.seconds, raw: true)
  rescue
    0
  end
 
  def self.alert_if_threshold(metric, count, context)
    threshold = THRESHOLDS[metric]
    return unless count && count >= threshold
 
    SecurityEventLogger.log(:suspicious_activity,
      metadata: { metric: metric, count: count, threshold: threshold }.merge(context)
    )
  end
end

チェックリスト

  • 構造化JSON形式でログを出力している(lograge等を使用)
  • すべてのリクエストにリクエストIDを付与している
  • 認証成功・失敗・ロックアウトをセキュリティログに記録している
  • データの作成・更新・削除を監査ログに記録している
  • 機密フィールドをログから除外している
  • 監査ログの変更・削除を禁止している
  • ログをS3またはCloudWatch Logsに集約している
  • AWS CloudTrailでインフラ操作を記録している
  • 異常検知のアラートを設定している
  • 法的保存期間を考慮したログ保管ポリシーを設定している