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