Spring Securityを本番環境で安全に運用するには、開発時とは異なる視点での設定確認と継続的なセキュリティ対策が必要です。本記事では、本番デプロイ前に確認すべきセキュリティチェックリストと、運用フェーズで必要となるベストプラクティスを体系的に解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Security 6.4.x
ビルドツール Maven または Gradle
CI/CD GitHub Actions または Jenkins

事前に以下の知識があると理解がスムーズです。

  • Spring Securityの基本的な設定方法
  • SecurityFilterChainの仕組み
  • ログ設定(Logback)の基礎
  • CI/CDパイプラインの基本概念

期待される学習成果

本記事を読み終えると、以下のことができるようになります。

  • 本番環境に最適なPasswordEncoderを選定し、適切なパラメータで設定できる
  • セキュリティ設定の監査ポイントを理解し、設定漏れを防止できる
  • 依存ライブラリの脆弱性を自動検出する仕組みを構築できる
  • セキュリティログの設計と監視体制を整備できる
  • インシデント発生時の対応準備を完了できる

本番運用チェックリスト概要

本番環境でSpring Securityを安全に運用するためのチェックリストを以下の5つのカテゴリに分類します。

flowchart TB
    subgraph checklist["本番運用チェックリスト"]
        A[1. PasswordEncoder設定]
        B[2. セキュリティ設定監査]
        C[3. 依存ライブラリ脆弱性チェック]
        D[4. セキュリティログ設計]
        E[5. インシデント対応準備]
    end
    
    A --> A1[アルゴリズム選定]
    A --> A2[ワークファクター調整]
    A --> A3[漏洩パスワードチェック]
    
    B --> B1[認証設定確認]
    B --> B2[認可設定確認]
    B --> B3[セキュリティヘッダー確認]
    
    C --> C1[Dependabot設定]
    C --> C2[OWASP Dependency-Check]
    C --> C3[CI/CD統合]
    
    D --> D1[認証イベントログ]
    D --> D2[監査ログ]
    D --> D3[アラート設定]
    
    E --> E1[対応フロー策定]
    E --> E2[連絡体制整備]
    E --> E3[復旧手順書作成]

各カテゴリの詳細を順に解説します。

本番環境向けPasswordEncoderの選定

パスワードの安全な保存は認証システムの基盤です。本番環境では、攻撃者による総当たり攻撃やレインボーテーブル攻撃に耐えられるアルゴリズムを選定する必要があります。

PasswordEncoderの選択基準

Spring Security 6.4では、以下のPasswordEncoderが利用可能です。本番環境での推奨度を比較します。

アルゴリズム 推奨度 特徴 用途
Argon2 最高 メモリハード関数、Password Hashing Competition優勝 新規システム
BCrypt 広く普及、十分な安全性 既存システム、互換性重視
SCrypt メモリハード関数、BCryptより強力 高セキュリティ要件
PBKDF2 FIPS認証対応 政府系・金融系システム

新規システムではArgon2を、既存システムや広い互換性が必要な場合はBCryptを選択することを推奨します。

Argon2PasswordEncoderの設定

Argon2はPassword Hashing Competitionで優勝したアルゴリズムで、メモリと計算時間の両方を消費することで、専用ハードウェアによる攻撃を困難にします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class PasswordEncoderConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // Spring Security 6.4推奨のデフォルト設定
        // saltLength: 16, hashLength: 32, parallelism: 1
        // memory: 19456 (19MB), iterations: 2
        return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
    }
}

高セキュリティ要件の場合は、パラメータをカスタマイズします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class HighSecurityPasswordEncoderConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // カスタムパラメータ
        // saltLength: ソルト長(バイト)
        // hashLength: ハッシュ長(バイト)
        // parallelism: 並列度
        // memory: メモリコスト(KB)
        // iterations: 反復回数
        int saltLength = 16;
        int hashLength = 32;
        int parallelism = 4;
        int memory = 65536; // 64MB
        int iterations = 3;
        
        return new Argon2PasswordEncoder(
            saltLength,
            hashLength,
            parallelism,
            memory,
            iterations
        );
    }
}

BCryptPasswordEncoderの設定

BCryptは広く普及しており、多くのシステムで互換性があります。strengthパラメータでワークファクターを調整します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class BCryptPasswordEncoderConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // strength: 4〜31の範囲で指定(デフォルト: 10)
        // 本番環境では12以上を推奨
        // 目安: システムで約1秒かかる値を選定
        int strength = 12;
        return new BCryptPasswordEncoder(strength);
    }
}

ワークファクターの調整方法

PasswordEncoderのワークファクター(計算コスト)は、パスワード検証に約1秒かかるように調整することが推奨されています。以下のコードで適切な値を測定できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;

public class WorkFactorBenchmark {

    public static void main(String[] args) {
        String testPassword = "testPassword123!";

        // BCryptのstrength値を測定
        for (int strength = 10; strength <= 14; strength++) {
            BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(strength);
            long start = System.currentTimeMillis();
            String encoded = encoder.encode(testPassword);
            encoder.matches(testPassword, encoded);
            long duration = System.currentTimeMillis() - start;
            System.out.printf("BCrypt strength=%d: %dms%n", strength, duration);
        }

        // Argon2のパラメータを測定
        int[] memoryValues = {16384, 32768, 65536};
        for (int memory : memoryValues) {
            Argon2PasswordEncoder encoder = new Argon2PasswordEncoder(
                16, 32, 1, memory, 2
            );
            long start = System.currentTimeMillis();
            String encoded = encoder.encode(testPassword);
            encoder.matches(testPassword, encoded);
            long duration = System.currentTimeMillis() - start;
            System.out.printf("Argon2 memory=%dKB: %dms%n", memory, duration);
        }
    }
}

実行結果の例(環境により異なります):

1
2
3
4
5
6
7
8
BCrypt strength=10: 98ms
BCrypt strength=11: 195ms
BCrypt strength=12: 389ms
BCrypt strength=13: 778ms
BCrypt strength=14: 1556ms
Argon2 memory=16384KB: 245ms
Argon2 memory=32768KB: 478ms
Argon2 memory=65536KB: 956ms

この場合、BCryptはstrength=13〜14、Argon2はmemory=65536KBが約1秒となり、本番環境に適しています。

漏洩パスワードのチェック

Spring Security 6.4では、Have I Been Pwned APIと連携して、漏洩したパスワードをチェックする機能が提供されています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.password.CompromisedPasswordChecker;
import org.springframework.security.web.authentication.password.HaveIBeenPwnedRestApiPasswordChecker;

@Configuration
public class CompromisedPasswordConfig {

    @Bean
    public CompromisedPasswordChecker compromisedPasswordChecker() {
        return new HaveIBeenPwnedRestApiPasswordChecker();
    }
}

この設定により、認証時に漏洩パスワードが検出された場合、CompromisedPasswordExceptionがスローされます。ユーザーにパスワード変更を促すハンドラを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.password.CompromisedPasswordException;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;

import java.io.IOException;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/reset-password", "/change-password").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .failureHandler(compromisedPasswordHandler())
            );
        return http.build();
    }

    private AuthenticationFailureHandler compromisedPasswordHandler() {
        return new AuthenticationFailureHandler() {
            private final SimpleUrlAuthenticationFailureHandler defaultHandler =
                new SimpleUrlAuthenticationFailureHandler("/login?error");
            private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

            @Override
            public void onAuthenticationFailure(
                    HttpServletRequest request,
                    HttpServletResponse response,
                    AuthenticationException exception) throws IOException, ServletException {
                
                if (exception instanceof CompromisedPasswordException) {
                    // 漏洩パスワードの場合はパスワードリセットページへ
                    redirectStrategy.sendRedirect(
                        request, response, "/reset-password?compromised=true"
                    );
                    return;
                }
                defaultHandler.onAuthenticationFailure(request, response, exception);
            }
        };
    }
}

セキュリティ設定の監査

本番デプロイ前に、SecurityFilterChainの設定を網羅的に監査します。

認証設定の監査チェックリスト

以下の項目を確認し、設定漏れがないことを検証します。

チェック項目 確認内容 重要度
PasswordEncoder 適切なアルゴリズム(Argon2/BCrypt)を使用しているか 必須
セッション固定攻撃対策 sessionManagement().sessionFixation().changeSessionId()が設定されているか 必須
Remember-Me 本番で不要な場合は無効化されているか
ログアウト処理 セッション無効化とCookie削除が設定されているか 必須
同時セッション制御 必要に応じてmaximumSessions()が設定されているか

監査用の設定クラス例を示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@Profile("production")
public class ProductionSecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 認証設定
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/dashboard", true)
                .failureUrl("/login?error=true")
            )
            // セッション管理
            .sessionManagement(session -> session
                .sessionFixation().changeSessionId()
                .maximumSessions(1)
                .maxSessionsPreventsLogin(true)
            )
            // ログアウト設定
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login?logout=true")
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
                .clearAuthentication(true)
            );
        
        return http.build();
    }
}

認可設定の監査チェックリスト

チェック項目 確認内容 重要度
デフォルト拒否 明示的に許可されていないパスはすべて拒否されるか 必須
公開エンドポイント permitAll()の対象が最小限か 必須
管理者エンドポイント 適切なロール制限が設定されているか 必須
アクチュエーター /actuator/**が適切に保護されているか 必須
静的リソース 必要最小限のパスのみ公開されているか
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;

@Configuration
public class AuthorizationAuditConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                // 静的リソース(最小限)
                .requestMatchers("/css/**", "/js/**", "/images/**").permitAll()
                // 公開エンドポイント(最小限)
                .requestMatchers("/login", "/error", "/health").permitAll()
                // アクチュエーター(管理者のみ)
                .requestMatchers(EndpointRequest.toAnyEndpoint())
                    .hasRole("ADMIN")
                // 管理者エンドポイント
                .requestMatchers("/admin/**").hasRole("ADMIN")
                // APIエンドポイント
                .requestMatchers("/api/**").hasAnyRole("USER", "ADMIN")
                // デフォルト拒否(重要)
                .anyRequest().authenticated()
            );
        
        return http.build();
    }
}

セキュリティヘッダーの監査チェックリスト

ヘッダー 推奨設定 目的
Content-Security-Policy default-src 'self' XSS攻撃防止
X-Frame-Options DENY クリックジャッキング防止
X-Content-Type-Options nosniff MIMEスニッフィング防止
Strict-Transport-Security max-age=31536000; includeSubDomains HTTPS強制
X-XSS-Protection 0 古いXSSフィルター無効化
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityHeadersConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .headers(headers -> headers
                // Content-Security-Policy
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives(
                        "default-src 'self'; " +
                        "script-src 'self'; " +
                        "style-src 'self' 'unsafe-inline'; " +
                        "img-src 'self' data:; " +
                        "font-src 'self'; " +
                        "frame-ancestors 'none'"
                    )
                )
                // X-Frame-Options
                .frameOptions(frame -> frame.deny())
                // X-Content-Type-Options(デフォルトで有効)
                .contentTypeOptions(contentType -> {})
                // HTTP Strict Transport Security
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31536000)
                )
                // Referrer-Policy
                .referrerPolicy(referrer -> referrer
                    .policy(org.springframework.security.web.header.writers
                        .ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
                )
                // Permissions-Policy
                .permissionsPolicy(permissions -> permissions
                    .policy("geolocation=(), camera=(), microphone=()")
                )
            );
        
        return http.build();
    }
}

設定監査の自動化

テストコードでセキュリティ設定を自動監査します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("production")
class SecurityAuditTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void 未認証ユーザーは保護されたエンドポイントにアクセスできない() throws Exception {
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void 管理者エンドポイントは一般ユーザーからアクセスできない() throws Exception {
        mockMvc.perform(get("/admin/settings"))
            .andExpect(status().isForbidden());
    }

    @Test
    void セキュリティヘッダーが正しく設定されている() throws Exception {
        mockMvc.perform(get("/login"))
            .andExpect(header().string("X-Frame-Options", "DENY"))
            .andExpect(header().string("X-Content-Type-Options", "nosniff"))
            .andExpect(header().exists("Content-Security-Policy"));
    }

    @Test
    void アクチュエーターエンドポイントは保護されている() throws Exception {
        mockMvc.perform(get("/actuator/env"))
            .andExpect(status().isUnauthorized());
    }
}

依存ライブラリの脆弱性チェック

サードパーティライブラリの既知の脆弱性は、アプリケーション全体のセキュリティリスクとなります。自動化された脆弱性チェックをCI/CDパイプラインに組み込みます。

Dependabotの設定

GitHubリポジトリでDependabotを有効化し、依存ライブラリの脆弱性を自動検出します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# .github/dependabot.yml
version: 2
updates:
  # Maven依存関係の更新
  - package-ecosystem: "maven"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
      time: "09:00"
      timezone: "Asia/Tokyo"
    open-pull-requests-limit: 10
    # セキュリティアップデートは自動マージ対象
    groups:
      security-updates:
        applies-to: security-updates
        patterns:
          - "*"
    labels:
      - "dependencies"
      - "security"
    commit-message:
      prefix: "deps"
      include: "scope"

  # Gradle依存関係の更新(Gradleプロジェクトの場合)
  - package-ecosystem: "gradle"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10

  # GitHub Actionsの更新
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

セキュリティアップデートの自動マージを設定するワークフローも追加します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# .github/workflows/dependabot-auto-merge.yml
name: Dependabot Auto Merge

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: write
  pull-requests: write

jobs:
  auto-merge:
    runs-on: ubuntu-latest
    if: github.actor == 'dependabot[bot]'
    steps:
      - name: Dependabot metadata
        id: metadata
        uses: dependabot/fetch-metadata@v2
        with:
          github-token: "${{ secrets.GITHUB_TOKEN }}"

      - name: Auto-merge security updates
        if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || 
            steps.metadata.outputs.update-type == 'version-update:semver-minor'
        run: gh pr merge --auto --squash "$PR_URL"
        env:
          PR_URL: ${{ github.event.pull_request.html_url }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

OWASP Dependency-Checkの導入

OWASP Dependency-Checkは、NVD(National Vulnerability Database)と連携して、より詳細な脆弱性分析を行います。

Mavenプロジェクトでの設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!-- pom.xml -->
<build>
    <plugins>
        <plugin>
            <groupId>org.owasp</groupId>
            <artifactId>dependency-check-maven</artifactId>
            <version>12.1.9</version>
            <configuration>
                <!-- CVSSスコア7以上でビルド失敗 -->
                <failBuildOnCVSS>7</failBuildOnCVSS>
                <!-- レポート形式 -->
                <formats>
                    <format>HTML</format>
                    <format>JSON</format>
                </formats>
                <!-- 除外設定ファイル -->
                <suppressionFile>dependency-check-suppressions.xml</suppressionFile>
                <!-- NVD API Key(推奨) -->
                <nvdApiKey>${env.NVD_API_KEY}</nvdApiKey>
            </configuration>
            <executions>
                <execution>
                    <goals>
                        <goal>check</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

Gradleプロジェクトでの設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// build.gradle
plugins {
    id 'org.owasp.dependencycheck' version '12.1.9'
}

dependencyCheck {
    // CVSSスコア7以上でビルド失敗
    failBuildOnCVSS = 7.0f
    // レポート形式
    formats = ['HTML', 'JSON']
    // 除外設定ファイル
    suppressionFile = 'dependency-check-suppressions.xml'
    // NVD API Key(推奨)
    nvd {
        apiKey = System.getenv('NVD_API_KEY')
    }
}

除外設定ファイル

誤検知や対応済みの脆弱性を除外する設定ファイルを作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- dependency-check-suppressions.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<suppressions xmlns="https://dependency-check.github.io/schema/suppression/3.0"
              xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
              xsi:schemaLocation="https://dependency-check.github.io/schema/suppression/3.0 
              https://dependency-check.github.io/schema/suppression/3.0/suppression.xsd">
    
    <!-- 例: 誤検知の除外 -->
    <suppress>
        <notes>
            <![CDATA[
            この脆弱性は当該アプリケーションの使用方法では影響を受けない。
            確認日: 2026-01-13
            確認者: security-team
            ]]>
        </notes>
        <cve>CVE-2024-XXXXX</cve>
    </suppress>

    <!-- 例: 特定のライブラリの除外 -->
    <suppress>
        <notes>対応バージョンへのアップデートを計画中(2026-02-01予定)</notes>
        <gav regex="true">^com\.example:vulnerable-lib:.*$</gav>
        <cve>CVE-2024-YYYYY</cve>
    </suppress>
</suppressions>

CI/CDパイプラインへの統合

GitHub Actionsで脆弱性チェックを自動実行します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# .github/workflows/security-check.yml
name: Security Check

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    # 毎日午前3時(JST)に実行
    - cron: '0 18 * * *'

jobs:
  dependency-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: 'maven'

      - name: Run OWASP Dependency-Check
        run: mvn dependency-check:check
        env:
          NVD_API_KEY: ${{ secrets.NVD_API_KEY }}

      - name: Upload Dependency-Check Report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: dependency-check-report
          path: target/dependency-check-report.html
          retention-days: 30

      - name: Notify on vulnerability detection
        if: failure()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
          webhook-type: incoming-webhook
          payload: |
            {
              "text": "脆弱性が検出されました: ${{ github.repository }}",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*リポジトリ:* ${{ github.repository }}\n*ブランチ:* ${{ github.ref_name }}\n*詳細:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|ワークフロー実行結果>"
                  }
                }
              ]
            }

セキュリティログの設計

セキュリティインシデントの検知と事後分析のために、適切なログ設計が不可欠です。

認証イベントのログ記録

Spring Securityの認証イベントをリスナーで捕捉し、構造化ログとして出力します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.security.authentication.event.LogoutSuccessEvent;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional;

@Component
public class AuthenticationEventListener {

    private static final Logger log = LoggerFactory.getLogger(AuthenticationEventListener.class);

    @EventListener
    public void onAuthenticationSuccess(AuthenticationSuccessEvent event) {
        Authentication auth = event.getAuthentication();
        String username = auth.getName();
        String ipAddress = getClientIpAddress();
        String userAgent = getUserAgent();

        log.info("認証成功 - username={}, ip={}, userAgent={}, authorities={}",
            username, ipAddress, userAgent, auth.getAuthorities());
    }

    @EventListener
    public void onAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
        String username = event.getAuthentication().getName();
        String ipAddress = getClientIpAddress();
        String failureReason = event.getException().getClass().getSimpleName();
        String failureMessage = event.getException().getMessage();

        log.warn("認証失敗 - username={}, ip={}, reason={}, message={}",
            username, ipAddress, failureReason, failureMessage);
    }

    @EventListener
    public void onLogoutSuccess(LogoutSuccessEvent event) {
        String username = event.getAuthentication().getName();
        String ipAddress = getClientIpAddress();

        log.info("ログアウト - username={}, ip={}", username, ipAddress);
    }

    private String getClientIpAddress() {
        return getRequest()
            .map(request -> {
                String xForwardedFor = request.getHeader("X-Forwarded-For");
                if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
                    return xForwardedFor.split(",")[0].trim();
                }
                return request.getRemoteAddr();
            })
            .orElse("unknown");
    }

    private String getUserAgent() {
        return getRequest()
            .map(request -> request.getHeader("User-Agent"))
            .orElse("unknown");
    }

    private Optional<HttpServletRequest> getRequest() {
        return Optional.ofNullable(RequestContextHolder.getRequestAttributes())
            .filter(ServletRequestAttributes.class::isInstance)
            .map(ServletRequestAttributes.class::cast)
            .map(ServletRequestAttributes::getRequest);
    }
}

構造化ログの設定

本番環境では、JSON形式の構造化ログを出力することで、ログ分析ツールとの連携が容易になります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!-- src/main/resources/logback-spring.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <springProfile name="production">
        <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <includeMdcKeyName>traceId</includeMdcKeyName>
                <includeMdcKeyName>spanId</includeMdcKeyName>
                <includeMdcKeyName>userId</includeMdcKeyName>
                <customFields>{"app":"myapp","env":"production"}</customFields>
            </encoder>
        </appender>

        <!-- セキュリティログ専用アペンダー -->
        <appender name="SECURITY_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>/var/log/myapp/security.log</file>
            <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
                <fileNamePattern>/var/log/myapp/security.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
                <maxHistory>90</maxHistory>
            </rollingPolicy>
            <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
        </appender>

        <logger name="com.example.security" level="INFO" additivity="false">
            <appender-ref ref="SECURITY_FILE"/>
            <appender-ref ref="JSON"/>
        </logger>

        <root level="INFO">
            <appender-ref ref="JSON"/>
        </root>
    </springProfile>
</configuration>

不正アクセス検知とアラート

連続した認証失敗を検知し、アラートを発報する仕組みを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

@Component
public class BruteForceDetector {

    private static final Logger log = LoggerFactory.getLogger(BruteForceDetector.class);
    private static final int MAX_ATTEMPTS = 5;
    private static final Duration WINDOW = Duration.ofMinutes(15);

    private final Map<String, AttemptInfo> attempts = new ConcurrentHashMap<>();
    private final AlertService alertService;

    public BruteForceDetector(AlertService alertService) {
        this.alertService = alertService;
    }

    @EventListener
    public void onAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
        String username = event.getAuthentication().getName();
        
        AttemptInfo info = attempts.compute(username, (key, existing) -> {
            if (existing == null || existing.isExpired()) {
                return new AttemptInfo();
            }
            existing.increment();
            return existing;
        });

        if (info.getCount() >= MAX_ATTEMPTS) {
            log.error("ブルートフォース攻撃の可能性 - username={}, attempts={}, window={}分",
                username, info.getCount(), WINDOW.toMinutes());
            
            alertService.sendAlert(
                "ブルートフォース攻撃検知",
                String.format("ユーザー '%s' に対して%d回の認証失敗が%d分以内に発生しました",
                    username, info.getCount(), WINDOW.toMinutes())
            );
        }
    }

    private static class AttemptInfo {
        private final Instant startTime = Instant.now();
        private final AtomicInteger count = new AtomicInteger(1);

        void increment() {
            count.incrementAndGet();
        }

        int getCount() {
            return count.get();
        }

        boolean isExpired() {
            return Instant.now().isAfter(startTime.plus(WINDOW));
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import org.springframework.stereotype.Service;

@Service
public class AlertService {

    public void sendAlert(String title, String message) {
        // Slack、PagerDuty、メールなどへの通知を実装
        // 例: Slack Webhook
    }
}

監査ログの実装

重要な操作(ユーザー作成、権限変更、設定変更など)の監査ログを記録します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.Arrays;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
    String action();
    String resource() default "";
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

import java.util.Arrays;

@Aspect
@Component
public class AuditAspect {

    private static final Logger auditLog = LoggerFactory.getLogger("AUDIT");

    @Around("@annotation(auditable)")
    public Object audit(ProceedingJoinPoint joinPoint, Auditable auditable) throws Throwable {
        String username = getCurrentUsername();
        String action = auditable.action();
        String resource = auditable.resource();
        String methodName = joinPoint.getSignature().getName();
        Object[] args = joinPoint.getArgs();

        long startTime = System.currentTimeMillis();
        Object result = null;
        String status = "SUCCESS";
        String errorMessage = null;

        try {
            result = joinPoint.proceed();
            return result;
        } catch (Exception e) {
            status = "FAILURE";
            errorMessage = e.getMessage();
            throw e;
        } finally {
            long duration = System.currentTimeMillis() - startTime;
            auditLog.info(
                "監査ログ - user={}, action={}, resource={}, method={}, args={}, " +
                "status={}, duration={}ms, error={}",
                username, action, resource, methodName, 
                Arrays.toString(args), status, duration, errorMessage
            );
        }
    }

    private String getCurrentUsername() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        return auth != null ? auth.getName() : "anonymous";
    }
}

使用例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import org.springframework.stereotype.Service;

@Service
public class UserService {

    @Auditable(action = "CREATE_USER", resource = "USER")
    public User createUser(CreateUserRequest request) {
        // ユーザー作成処理
    }

    @Auditable(action = "UPDATE_ROLE", resource = "USER")
    public void updateUserRole(Long userId, String newRole) {
        // 権限変更処理
    }

    @Auditable(action = "DELETE_USER", resource = "USER")
    public void deleteUser(Long userId) {
        // ユーザー削除処理
    }
}

インシデント対応の準備

セキュリティインシデントが発生した際に迅速に対応できるよう、事前準備を行います。

インシデント対応フロー

flowchart TD
    A[インシデント検知] --> B{重大度判定}
    B -->|Critical| C[即座にエスカレーション]
    B -->|High| D[1時間以内に対応開始]
    B -->|Medium| E[24時間以内に対応]
    B -->|Low| F[次回スプリントで対応]
    
    C --> G[対応チーム招集]
    D --> G
    
    G --> H[影響範囲の特定]
    H --> I[一次対応・封じ込め]
    I --> J[根本原因分析]
    J --> K[恒久対策実施]
    K --> L[事後レビュー・報告]
    
    I --> M{アカウント侵害?}
    M -->|Yes| N[セッション無効化]
    M -->|Yes| O[パスワードリセット強制]
    N --> J
    O --> J

セッション強制無効化機能

インシデント発生時に特定ユーザーのセッションを強制無効化する機能を実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class SessionInvalidationService {

    private final SessionRegistry sessionRegistry;

    public SessionInvalidationService(SessionRegistry sessionRegistry) {
        this.sessionRegistry = sessionRegistry;
    }

    /**
     * 特定ユーザーの全セッションを無効化
     */
    public void invalidateUserSessions(String username) {
        List<Object> principals = sessionRegistry.getAllPrincipals();
        
        for (Object principal : principals) {
            if (principal instanceof org.springframework.security.core.userdetails.UserDetails userDetails) {
                if (userDetails.getUsername().equals(username)) {
                    List<SessionInformation> sessions = 
                        sessionRegistry.getAllSessions(principal, false);
                    for (SessionInformation session : sessions) {
                        session.expireNow();
                    }
                }
            }
        }
    }

    /**
     * 全ユーザーのセッションを無効化(緊急時)
     */
    public void invalidateAllSessions() {
        List<Object> principals = sessionRegistry.getAllPrincipals();
        
        for (Object principal : principals) {
            List<SessionInformation> sessions = 
                sessionRegistry.getAllSessions(principal, false);
            for (SessionInformation session : sessions) {
                session.expireNow();
            }
        }
    }
}

SessionRegistryをBean登録します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;

@Configuration
public class SessionConfig {

    @Bean
    public SessionRegistry sessionRegistry() {
        return new SessionRegistryImpl();
    }
}

パスワードリセット強制機能

アカウント侵害が疑われる場合に、パスワードリセットを強制する機能を実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PasswordResetService {

    private final UserRepository userRepository;
    private final SessionInvalidationService sessionInvalidationService;

    public PasswordResetService(
            UserRepository userRepository,
            SessionInvalidationService sessionInvalidationService) {
        this.userRepository = userRepository;
        this.sessionInvalidationService = sessionInvalidationService;
    }

    /**
     * パスワードリセットを強制(次回ログイン時に変更必須)
     */
    @Transactional
    public void forcePasswordReset(String username) {
        userRepository.findByUsername(username).ifPresent(user -> {
            user.setCredentialsExpired(true);
            userRepository.save(user);
            
            // 既存セッションを無効化
            sessionInvalidationService.invalidateUserSessions(username);
        });
    }

    /**
     * 複数ユーザーのパスワードリセットを強制
     */
    @Transactional
    public void forcePasswordResetBatch(List<String> usernames) {
        for (String username : usernames) {
            forcePasswordReset(username);
        }
    }
}

緊急連絡体制の設定

インシデント発生時の連絡体制を設定ファイルで管理します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# application-production.yml
security:
  incident:
    contacts:
      - name: "セキュリティチーム"
        email: "security@example.com"
        slack: "#security-alerts"
        phone: "03-XXXX-XXXX"
      - name: "インフラチーム"
        email: "infra@example.com"
        slack: "#infra-alerts"
    escalation:
      critical:
        notify-within-minutes: 5
        contacts: ["security@example.com", "cto@example.com"]
      high:
        notify-within-minutes: 60
        contacts: ["security@example.com"]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
@ConfigurationProperties(prefix = "security.incident")
public class IncidentContactProperties {

    private List<Contact> contacts;
    private Escalation escalation;

    // getters, setters

    public static class Contact {
        private String name;
        private String email;
        private String slack;
        private String phone;
        // getters, setters
    }

    public static class Escalation {
        private EscalationLevel critical;
        private EscalationLevel high;
        // getters, setters
    }

    public static class EscalationLevel {
        private int notifyWithinMinutes;
        private List<String> contacts;
        // getters, setters
    }
}

本番デプロイ前チェックリスト(まとめ)

最後に、本番デプロイ前に確認すべき項目をまとめます。

PasswordEncoder設定

  • 適切なアルゴリズム(Argon2またはBCrypt)を選定した
  • ワークファクターを約1秒になるよう調整した
  • 漏洩パスワードチェック(Have I Been Pwned連携)を有効化した

セキュリティ設定

  • セッション固定攻撃対策が有効になっている
  • ログアウト時にセッションとCookieが無効化される
  • デフォルトで全てのエンドポイントが認証必須になっている
  • アクチュエーターエンドポイントが保護されている
  • セキュリティヘッダー(CSP、X-Frame-Options等)が設定されている
  • HTTPSが強制されている(HSTS設定済み)

依存ライブラリ

  • Dependabotが有効化されている
  • OWASP Dependency-CheckがCI/CDに組み込まれている
  • CVSSスコア7以上の脆弱性でビルドが失敗する設定になっている

ログ・監視

  • 認証イベント(成功、失敗、ログアウト)がログ出力される
  • ブルートフォース攻撃の検知とアラートが設定されている
  • 重要操作の監査ログが記録される
  • ログが構造化フォーマット(JSON)で出力される

インシデント対応

  • セッション強制無効化機能が実装されている
  • パスワードリセット強制機能が実装されている
  • 緊急連絡体制が文書化されている
  • インシデント対応フローが策定されている

まとめ

本記事では、Spring Securityを本番環境で安全に運用するためのチェックリストとベストプラクティスを解説しました。セキュリティは一度設定して終わりではなく、継続的な監視と改善が必要です。定期的にこのチェックリストを見直し、新たな脅威に対応できる体制を維持してください。

参考リンク