001package com.streamconverter.command.rule; 002 003import com.streamconverter.StreamProcessingException; 004import java.sql.Connection; 005import java.sql.DriverManager; 006import java.sql.PreparedStatement; 007import java.sql.ResultSet; 008import java.sql.ResultSetMetaData; 009import java.sql.SQLException; 010import java.util.Objects; 011import java.util.regex.Pattern; 012import org.slf4j.Logger; 013import org.slf4j.LoggerFactory; 014 015/** 016 * データベースからデータを取得するルール 017 * 018 * <p>このクラスは、データベースからデータを取得するためのルールを定義します。 具体的なデータベース接続やクエリ実行のロジックは、このクラスで実装されます。 019 * 020 * <p>使用例: 021 * 022 * <pre>{@code 023 * // 基本的な使用例 024 * DatabaseFetchRule rule = new DatabaseFetchRule( 025 * "jdbc:h2:mem:testdb", 026 * "SELECT name FROM users WHERE id = ?" 027 * ); 028 * String result = rule.apply("123"); // ユーザーID 123 の名前を取得 029 * 030 * // NavigateCommandと組み合わせた使用例 031 * JsonNavigateCommand command = new JsonNavigateCommand("$.userId", rule); 032 * command.execute(inputStream, outputStream); // JSON中のuserIdでDBを検索して置換 033 * }</pre> 034 * 035 * <p>セキュリティ機能: 036 * 037 * <ul> 038 * <li>SELECTクエリのみ許可(INSERT/UPDATE/DELETE等は禁止) 039 * <li>SQLインジェクション攻撃の検出と防止 040 * <li>許可されたデータベーススキーマのみ接続可能 041 * <li>入力パラメータの自動サニタイズ 042 * </ul> 043 */ 044public class DatabaseFetchRule implements IRule { 045 private static final Logger logger = LoggerFactory.getLogger(DatabaseFetchRule.class); 046 047 /** 許可されるデータベースURLスキーマ(テスト用のmockも含む) */ 048 private static final Pattern ALLOWED_DB_SCHEME_PATTERN = 049 Pattern.compile( 050 "^jdbc:(h2|hsqldb|sqlite|postgresql|mysql|mock):.*", Pattern.CASE_INSENSITIVE); 051 052 private final String databaseUrl; 053 private final String query; 054 055 /** 056 * コンストラクタ 057 * 058 * <p>データベースのURLとクエリを指定して、DatabaseFetchRuleのインスタンスを初期化します。 059 * クエリは一意な結果を返すように設計されるべきです(例:DISTINCT、LIMIT句の使用など)。 セキュリティのため、URLとクエリの検証を実行します。 060 * 061 * @param databaseUrl データベースのURL(許可されたスキーマのみ) 062 * @param query データベースに対するクエリ(SELECTクエリのみ許可) 063 * @throws IllegalArgumentException 無効なパラメータが指定された場合 064 * @throws SecurityException セキュリティ違反が検出された場合 065 */ 066 public DatabaseFetchRule(String databaseUrl, String query) { 067 Objects.requireNonNull(databaseUrl, "Database URL cannot be null"); 068 Objects.requireNonNull(query, "Query cannot be null"); 069 070 // データベースURLの検証 071 this.databaseUrl = validateDatabaseUrl(databaseUrl.trim()); 072 073 // クエリの検証 074 this.query = validateQuery(query.trim()); 075 076 logger.info( 077 "DatabaseFetchRule initialized with secure validation - URL: {}, Query length: {}", 078 this.databaseUrl, 079 this.query.length()); 080 } 081 082 /** 083 * データベースURLを検証します(セキュリティ対策) 084 * 085 * @param url 検証対象のURL 086 * @return 検証済みのURL 087 * @throws SecurityException 不正なURLが検出された場合 088 */ 089 private String validateDatabaseUrl(String url) { 090 if (url.isEmpty()) { 091 throw new IllegalArgumentException("Database URL cannot be empty"); 092 } 093 094 // 許可されたスキーマのチェック 095 if (!ALLOWED_DB_SCHEME_PATTERN.matcher(url).matches()) { 096 throw new SecurityException( 097 "Database URL uses unsupported or potentially dangerous scheme: " + url); 098 } 099 100 // 危険な文字列の検出 101 if (url.contains("..") || url.contains("file:") || url.contains("javascript:")) { 102 throw new SecurityException("Database URL contains potentially dangerous patterns: " + url); 103 } 104 105 logger.debug("Database URL validation passed: {}", url); 106 return url; 107 } 108 109 /** 110 * クエリを検証します(SQLインジェクション対策) 111 * 112 * @param queryString 検証対象のクエリ 113 * @return 検証済みのクエリ 114 * @throws SecurityException SQLインジェクションが検出された場合 115 */ 116 private String validateQuery(String queryString) { 117 return SqlQueryUtils.validateQuery(queryString, logger); 118 } 119 120 private String sanitizeInput(String input) { 121 return SqlQueryUtils.sanitizeInput(input, logger); 122 } 123 124 /** 125 * ルールの適用を実行します。 126 * 127 * <p>このメソッドは、ストリーム変換の際にルールを適用するために使用されます。 データベースからデータを取得するロジックを実装します。 結果セットの先頭行・先頭列の値を返却します。 128 * 結果が1行1列でない場合は警告をログに出力します。 129 * 130 * @param input 変換対象の文字列(クエリパラメータとして使用) 131 * @return String output クエリ結果の先頭値、または空文字列(結果がない場合) 132 */ 133 @Override 134 public String apply(String input) { 135 try (Connection connection = DriverManager.getConnection(databaseUrl); 136 PreparedStatement statement = connection.prepareStatement(query)) { 137 138 // データベース接続 139 logger.debug("データベースに接続: {}", databaseUrl); 140 141 // 入力文字列をパラメータとして設定(クエリに「?」プレースホルダーがある場合) 142 if (query.contains("?") && input != null && !input.isEmpty()) { 143 // 入力値のサニタイズとセキュリティチェック 144 String sanitizedInput = sanitizeInput(input); 145 if (sanitizedInput.isEmpty()) { 146 logger.warn( 147 "Input parameter was sanitized to empty string. Rejecting input for security reasons. Original input: {}", 148 input); 149 return ""; 150 } 151 152 statement.setString(1, sanitizedInput); 153 logger.debug("Parameter set for prepared statement: length={}", sanitizedInput.length()); 154 } 155 156 // クエリ実行 157 logger.debug("クエリを実行: {}", query); 158 try (ResultSet resultSet = statement.executeQuery()) { 159 // 結果の検証と処理 160 ResultSetMetaData metaData = resultSet.getMetaData(); 161 int columnCount = metaData.getColumnCount(); 162 163 // 結果がない場合 164 if (!resultSet.next()) { 165 logger.warn("クエリ結果が空です。"); 166 return ""; 167 } 168 169 // 列数の検証 170 if (columnCount != 1) { 171 logger.warn("クエリ結果が一列ではありません。列数: {}。先頭列の値を使用します。", columnCount); 172 } 173 174 // 先頭行の先頭列の値を取得 175 String value = resultSet.getString(1); 176 177 // 追加の行があるかチェック 178 boolean hasMoreRows = resultSet.next(); 179 if (hasMoreRows) { 180 logger.warn("クエリ結果が複数行あります。先頭行の値を使用します。"); 181 } 182 183 // nullチェック 184 if (value == null) { 185 logger.info("クエリ結果の先頭値がNULLです。"); 186 return ""; // NULLの場合は空文字列を返す 187 } 188 189 // 結果が理想的(1行1列)かどうかをログに記録 190 if (columnCount == 1 && !hasMoreRows) { 191 logger.info("データベースから単一値を取得しました: {}", value); 192 } else { 193 logger.info("データベースから先頭値を取得しました: {}", value); 194 } 195 196 return value; 197 } 198 } catch (SQLException e) { 199 logger.error("データベース操作中にエラーが発生しました: {}", e.getMessage(), e); 200 Throwable[] suppressed = e.getSuppressed(); 201 if (suppressed != null) { 202 for (Throwable s : suppressed) { 203 logger.error("クローズ中に追加のエラーが発生しました: {}", s.getMessage(), s); 204 } 205 } 206 throw new StreamProcessingException( 207 "データベースフェッチに失敗しました: " + e.getMessage(), e); 208 } 209 } 210}