Class: Benedictus::SafetyGuard

Inherits:
Object
  • Object
show all
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

Class Method Details

.active_record_class?(klass) ⇒ Boolean

Returns:

  • (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? && contains_no_data_modifying_cte?(normalized)

  raise Benedictus::UnsafeQueryError,
        "--analyze can only be used with SELECT queries (got: #{keyword.empty? ? "?" : keyword}...)"
end

.contains_no_data_modifying_cte?(normalized) ⇒ Boolean

Returns:

  • (Boolean)


92
93
94
# File 'lib/benedictus/safety_guard.rb', line 92

def self.contains_no_data_modifying_cte?(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