Class: Benedictus::SafetyGuard
- Inherits:
-
Object
- Object
- Benedictus::SafetyGuard
- Defined in:
- lib/benedictus/safety_guard.rb
Constant Summary collapse
- DATA_MODIFYING_CTE =
/\b(insert|update|delete|merge|truncate)\b/i
Class Method Summary collapse
- .active_record_class?(klass) ⇒ Boolean
- .assert_select!(sql) ⇒ Object
- .contains_no_data_modifying_cte?(normalized) ⇒ Boolean
- .first_keyword(normalized) ⇒ Object
- .match_dollar_tag(sql, i) ⇒ Object
-
.normalize(sql) ⇒ Object
Replaces SQL string literals (single-quoted, dollar-quoted) and comments (line ‘–`, nested block `/* */`) with single spaces, preserving token boundaries.
- .reject_multi_statement!(normalized) ⇒ Object
- .skip_dollar_quoted(sql, start, tag) ⇒ Object
- .skip_nested_block_comment(sql, start) ⇒ Object
- .skip_single_quoted(sql, start) ⇒ Object
- .with_rollback(klass) ⇒ Object
Class Method Details
.active_record_class?(klass) ⇒ Boolean
34 35 36 37 38 39 40 41 |
# File 'lib/benedictus/safety_guard.rb', line 34 def self.active_record_class?(klass) return false unless defined?(ActiveRecord::Base) return false unless klass.is_a?(Class) klass <= ActiveRecord::Base rescue TypeError, ArgumentError false end |
.assert_select!(sql) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 19 |
# File 'lib/benedictus/safety_guard.rb', line 7 def self.assert_select!(sql) normalized = normalize(sql) reject_multi_statement!(normalized) keyword = first_keyword(normalized) return if keyword.casecmp("SELECT").zero? return if keyword.casecmp("WITH").zero? && (normalized) raise Benedictus::UnsafeQueryError, "--analyze can only be used with SELECT queries (got: #{keyword.empty? ? "?" : keyword}...)" end |
.contains_no_data_modifying_cte?(normalized) ⇒ Boolean
92 93 94 |
# File 'lib/benedictus/safety_guard.rb', line 92 def self.(normalized) !DATA_MODIFYING_CTE.match?(normalized) end |
.first_keyword(normalized) ⇒ Object
78 79 80 |
# File 'lib/benedictus/safety_guard.rb', line 78 def self.first_keyword(normalized) normalized.lstrip[/\A[A-Za-z_]+/].to_s end |
.match_dollar_tag(sql, i) ⇒ Object
113 114 115 116 |
# File 'lib/benedictus/safety_guard.rb', line 113 def self.match_dollar_tag(sql, i) m = sql[i..].match(/\A\$([A-Za-z_]\w*)?\$/) m && m[0] end |
.normalize(sql) ⇒ Object
Replaces SQL string literals (single-quoted, dollar-quoted) and comments (line ‘–`, nested block `/* */`) with single spaces, preserving token boundaries. The result has identical keyword structure to the original but cannot bypass keyword scans by hiding payloads in comments or string literals.
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 75 76 |
# File 'lib/benedictus/safety_guard.rb', line 48 def self.normalize(sql) result = String.new i = 0 n = sql.length while i < n c = sql[i] nxt = sql[i + 1] if c == "'" i = skip_single_quoted(sql, i) result << " " elsif c == "$" && (tag = match_dollar_tag(sql, i)) i = skip_dollar_quoted(sql, i, tag) result << " " elsif c == "-" && nxt == "-" i = sql.index("\n", i + 2) || n result << " " elsif c == "/" && nxt == "*" i = skip_nested_block_comment(sql, i) result << " " else result << c i += 1 end end result end |
.reject_multi_statement!(normalized) ⇒ Object
82 83 84 85 86 87 88 89 90 |
# File 'lib/benedictus/safety_guard.rb', line 82 def self.reject_multi_statement!(normalized) trimmed = normalized.rstrip trimmed = trimmed.chomp(";").rstrip if trimmed.end_with?(";") return unless trimmed.include?(";") raise Benedictus::UnsafeQueryError, "--analyze refuses multi-statement SQL (found ';' between statements)" end |
.skip_dollar_quoted(sql, start, tag) ⇒ Object
118 119 120 121 122 |
# File 'lib/benedictus/safety_guard.rb', line 118 def self.skip_dollar_quoted(sql, start, tag) i = start + tag.length end_idx = sql.index(tag, i) end_idx ? end_idx + tag.length : sql.length end |
.skip_nested_block_comment(sql, start) ⇒ Object
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/benedictus/safety_guard.rb', line 124 def self.skip_nested_block_comment(sql, start) i = start + 2 depth = 1 n = sql.length while i < n && depth.positive? if sql[i] == "/" && sql[i + 1] == "*" depth += 1 i += 2 elsif sql[i] == "*" && sql[i + 1] == "/" depth -= 1 i += 2 else i += 1 end end i end |
.skip_single_quoted(sql, start) ⇒ Object
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 |
# File 'lib/benedictus/safety_guard.rb', line 96 def self.skip_single_quoted(sql, start) i = start + 1 n = sql.length while i < n if sql[i] == "'" && sql[i + 1] == "'" i += 2 elsif sql[i] == "'" return i + 1 else i += 1 end end n end |
.with_rollback(klass) ⇒ Object
21 22 23 24 25 26 27 28 29 30 31 32 |
# File 'lib/benedictus/safety_guard.rb', line 21 def self.with_rollback(klass) unless klass.respond_to?(:transaction) && active_record_class?(klass) raise ArgumentError, "klass must be an ActiveRecord::Base subclass" end result = nil klass.transaction(requires_new: true) do result = yield raise ActiveRecord::Rollback end result end |