Module: QueryGuard::Security

Defined in:
lib/query_guard/security.rb

Class Method Summary collapse

Class Method Details

.fingerprint(sql) ⇒ Object

Normalize SQL into a stable fingerprint:

  • collapse whitespace
  • replace quoted strings + numbers with ?


11
12
13
14
15
16
17
# File 'lib/query_guard/security.rb', line 11

def fingerprint(sql)
  s = sql.to_s.dup
  s.gsub!(/\s+/, " ")
  s.gsub!(/'(?:''|[^'])*'/, "?") # strings
  s.gsub!(/\b\d+\b/, "?")        # integers
  Digest::SHA1.hexdigest(s.strip.downcase)
end

.possible_exfiltration_query?(sql) ⇒ Boolean

Returns:

  • (Boolean)


24
25
26
27
28
29
30
31
# File 'lib/query_guard/security.rb', line 24

def possible_exfiltration_query?(sql)
  s = sql.to_s.strip
  return false unless s =~ /\ASELECT\b/i
  # Heuristic: SELECT without WHERE and without LIMIT
  no_where = !s.match?(/\bwhere\b/i)
  no_limit = !s.match?(/\blimit\b/i)
  no_where && no_limit
end

.post_request_checks!(env, stats, config) ⇒ Object



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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/query_guard/security.rb', line 33

def post_request_checks!(env, stats, config)
  return unless config.enable_security

  actor = resolve_actor(env, config)
  store = config.store || QueryGuard::Store.new

  # --- Unusual query pattern (rate/variety) ---
  if config.detect_unusual_query_pattern
    bucket = Time.now.utc.strftime("%Y%m%d%H%M") # minute bucket
    base = "qg:actor:#{actor}:#{bucket}"

    total = store.incr("#{base}:queries", ttl: 120, by: stats[:count].to_i)
    uniq_count = stats[:fingerprints]&.keys&.size.to_i
    store.add_to_set("#{base}:uniqfp", stats[:request_id], ttl: 120) # keep request marker

    uniq_fp_total = store.incr("#{base}:uniqfp_count", ttl: 120, by: uniq_count)

    if total > config.max_queries_per_minute_per_actor
      stats[:violations] << {
        type: :unusual_query_rate,
        actor: actor,
        per_minute: total,
        limit: config.max_queries_per_minute_per_actor
      }
    end

    if uniq_fp_total > config.max_unique_query_fingerprints_per_minute_per_actor
      stats[:violations] << {
        type: :unusual_query_variety,
        actor: actor,
        unique_fingerprints_per_minute: uniq_fp_total,
        limit: config.max_unique_query_fingerprints_per_minute_per_actor
      }
    end
  end

  # --- Data exfiltration (response size + endpoint hint) ---
  if config.detect_data_exfiltration
    bytes = stats[:response_bytes].to_i
    path  = env["PATH_INFO"].to_s

    if bytes > config.max_response_bytes_per_request
      stats[:violations] << {
        type: :data_exfiltration_large_response,
        bytes: bytes,
        limit: config.max_response_bytes_per_request,
        path: path
      }
    end

    if bytes > (config.max_response_bytes_per_request / 2) && path.match?(config.exfiltration_path_regex)
      stats[:violations] << {
        type: :data_exfiltration_suspected_export,
        bytes: bytes,
        path: path
      }
    end
  end
end

.resolve_actor(env, config) ⇒ Object



93
94
95
96
97
# File 'lib/query_guard/security.rb', line 93

def resolve_actor(env, config)
  (config.actor_resolver && config.actor_resolver.call(env)) || "unknown"
rescue
  "unknown"
end

.suspicious_sql_injection?(sql, patterns) ⇒ Boolean

Returns:

  • (Boolean)


19
20
21
22
# File 'lib/query_guard/security.rb', line 19

def suspicious_sql_injection?(sql, patterns)
  s = sql.to_s
  patterns.any? { |re| re.match?(s) }
end