ロギングと監査 — 何が起きたかを追跡する
「攻撃者が何をしたか、どこまで侵入したか、正確にわかりますか?」
弁護士からのメールが届いたのは、インシデントから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
endCloudWatch 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
endAWS 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
endINFO
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でインフラ操作を記録している
- 異常検知のアラートを設定している
- 法的保存期間を考慮したログ保管ポリシーを設定している