query_guard 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -1
- data/DESIGN.md +420 -0
- data/INDEX.md +309 -0
- data/README.md +579 -30
- data/exe/queryguard +23 -0
- data/lib/query_guard/action_controller_subscriber.rb +27 -0
- data/lib/query_guard/analysis/query_risk_classifier.rb +124 -0
- data/lib/query_guard/analysis/risk_detectors.rb +258 -0
- data/lib/query_guard/analysis/risk_level.rb +35 -0
- data/lib/query_guard/analyzers/base.rb +30 -0
- data/lib/query_guard/analyzers/query_count_analyzer.rb +31 -0
- data/lib/query_guard/analyzers/query_risk_analyzer.rb +146 -0
- data/lib/query_guard/analyzers/registry.rb +57 -0
- data/lib/query_guard/analyzers/select_star_analyzer.rb +42 -0
- data/lib/query_guard/analyzers/slow_query_analyzer.rb +39 -0
- data/lib/query_guard/budget.rb +148 -0
- data/lib/query_guard/cli/batch_report_formatter.rb +129 -0
- data/lib/query_guard/cli/command.rb +93 -0
- data/lib/query_guard/cli/commands/analyze.rb +52 -0
- data/lib/query_guard/cli/commands/check.rb +58 -0
- data/lib/query_guard/cli/formatter.rb +278 -0
- data/lib/query_guard/cli/json_reporter.rb +247 -0
- data/lib/query_guard/cli/paged_report_formatter.rb +137 -0
- data/lib/query_guard/cli/source_metadata_collector.rb +297 -0
- data/lib/query_guard/cli.rb +197 -0
- data/lib/query_guard/client.rb +4 -6
- data/lib/query_guard/config.rb +145 -6
- data/lib/query_guard/core/context.rb +80 -0
- data/lib/query_guard/core/finding.rb +162 -0
- data/lib/query_guard/core/finding_builders.rb +152 -0
- data/lib/query_guard/core/query.rb +40 -0
- data/lib/query_guard/explain/adapter_interface.rb +89 -0
- data/lib/query_guard/explain/explain_enricher.rb +367 -0
- data/lib/query_guard/explain/plan_signals.rb +385 -0
- data/lib/query_guard/explain/postgresql_adapter.rb +208 -0
- data/lib/query_guard/exporter.rb +124 -0
- data/lib/query_guard/fingerprint.rb +96 -0
- data/lib/query_guard/middleware.rb +101 -15
- data/lib/query_guard/migrations/database_adapter.rb +88 -0
- data/lib/query_guard/migrations/migration_analyzer.rb +100 -0
- data/lib/query_guard/migrations/migration_risk_detectors.rb +287 -0
- data/lib/query_guard/migrations/postgresql_adapter.rb +157 -0
- data/lib/query_guard/migrations/table_risk_analyzer.rb +154 -0
- data/lib/query_guard/migrations/table_size_resolver.rb +152 -0
- data/lib/query_guard/publish.rb +38 -0
- data/lib/query_guard/rspec.rb +119 -0
- data/lib/query_guard/security.rb +99 -0
- data/lib/query_guard/store.rb +38 -0
- data/lib/query_guard/subscriber.rb +46 -15
- data/lib/query_guard/suggest/index_suggester.rb +176 -0
- data/lib/query_guard/suggest/pattern_extractors.rb +137 -0
- data/lib/query_guard/trace.rb +106 -0
- data/lib/query_guard/uploader/http_uploader.rb +166 -0
- data/lib/query_guard/uploader/interface.rb +79 -0
- data/lib/query_guard/uploader/no_op_uploader.rb +46 -0
- data/lib/query_guard/uploader/registry.rb +37 -0
- data/lib/query_guard/uploader/upload_service.rb +80 -0
- data/lib/query_guard/version.rb +1 -1
- data/lib/query_guard.rb +54 -7
- metadata +78 -10
- data/.rspec +0 -3
- data/Rakefile +0 -21
- data/config/initializers/query_guard.rb +0 -9
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "digest"
|
|
8
|
+
|
|
9
|
+
module QueryGuard
|
|
10
|
+
class Exporter
|
|
11
|
+
def initialize(config)
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def enabled?
|
|
16
|
+
@config.base_url && @config.api_key && @config.project
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def export!(stats)
|
|
20
|
+
return unless enabled?
|
|
21
|
+
|
|
22
|
+
payload = build_payload(stats)
|
|
23
|
+
return if payload[:events].empty?
|
|
24
|
+
|
|
25
|
+
if @config.export_mode.to_sym == :async
|
|
26
|
+
Thread.new { post(payload) }
|
|
27
|
+
else
|
|
28
|
+
post(payload)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def build_payload(stats)
|
|
35
|
+
events = []
|
|
36
|
+
|
|
37
|
+
# Query events
|
|
38
|
+
if @config.export_queries.to_sym == :all
|
|
39
|
+
stats.fetch(:queries, []).each do |q|
|
|
40
|
+
events << {
|
|
41
|
+
type: "query",
|
|
42
|
+
statement: q[:sql],
|
|
43
|
+
durationMs: q[:duration_ms],
|
|
44
|
+
timestamp: q[:occurred_at],
|
|
45
|
+
originApp: @config.origin_app,
|
|
46
|
+
fingerprint: fingerprint(q[:sql]),
|
|
47
|
+
metadata: stats[:request] || {}
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Threat events from violations
|
|
53
|
+
stats.fetch(:violations, []).each do |v|
|
|
54
|
+
events << violation_to_threat(v, stats)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
projectId: @config.project,
|
|
59
|
+
events: events.compact
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def fingerprint(sql)
|
|
64
|
+
# cheap “normalized” hash
|
|
65
|
+
normalized = sql.to_s.gsub(/\s+/, " ").strip
|
|
66
|
+
Digest::SHA1.hexdigest(normalized)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def violation_to_threat(v, stats)
|
|
70
|
+
case v[:type]&.to_sym
|
|
71
|
+
when :slow_query
|
|
72
|
+
{
|
|
73
|
+
type: "threat",
|
|
74
|
+
severity: "medium",
|
|
75
|
+
threatType: "SlowQuery",
|
|
76
|
+
description: "Slow query #{v[:duration_ms]}ms",
|
|
77
|
+
timestamp: Time.now.utc.iso8601,
|
|
78
|
+
originApp: @config.origin_app,
|
|
79
|
+
metadata: {
|
|
80
|
+
sql: v[:sql],
|
|
81
|
+
request: stats[:request] || {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
when :select_star
|
|
85
|
+
{
|
|
86
|
+
type: "threat",
|
|
87
|
+
severity: "low",
|
|
88
|
+
threatType: "SelectStar",
|
|
89
|
+
description: "SELECT * detected",
|
|
90
|
+
timestamp: Time.now.utc.iso8601,
|
|
91
|
+
originApp: @config.origin_app,
|
|
92
|
+
metadata: {
|
|
93
|
+
sql: v[:sql],
|
|
94
|
+
request: stats[:request] || {}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
when :too_many_queries
|
|
98
|
+
{
|
|
99
|
+
type: "threat",
|
|
100
|
+
severity: "high",
|
|
101
|
+
threatType: "TooManyQueries",
|
|
102
|
+
description: "Query count #{v[:count]} exceeded limit #{v[:limit]}",
|
|
103
|
+
timestamp: Time.now.utc.iso8601,
|
|
104
|
+
originApp: @config.origin_app,
|
|
105
|
+
metadata: { request: stats[:request] || {} }
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def post(payload)
|
|
111
|
+
uri = URI.join(@config.base_url.to_s, "/api/v1/events")
|
|
112
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
113
|
+
http.use_ssl = (uri.scheme == "https")
|
|
114
|
+
req = Net::HTTP::Post.new(uri.request_uri)
|
|
115
|
+
req["Content-Type"] = "application/json"
|
|
116
|
+
req["Authorization"] = "Bearer #{@config.api_key}"
|
|
117
|
+
req.body = JSON.generate(payload)
|
|
118
|
+
http.request(req)
|
|
119
|
+
rescue => e
|
|
120
|
+
# Don’t crash the app if monitoring fails
|
|
121
|
+
warn "#{@config.log_prefix} export failed: #{e.class}: #{e.message}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "digest"
|
|
3
|
+
|
|
4
|
+
module QueryGuard
|
|
5
|
+
# SQL fingerprinting and statistics module.
|
|
6
|
+
# Normalizes SQL queries and tracks per-fingerprint statistics.
|
|
7
|
+
module Fingerprint
|
|
8
|
+
class << self
|
|
9
|
+
# Generate a stable fingerprint for a SQL query by:
|
|
10
|
+
# - Normalizing whitespace
|
|
11
|
+
# - Replacing string literals with ?
|
|
12
|
+
# - Replacing numeric literals with ?
|
|
13
|
+
# - Replacing IN (...) lists with IN (?)
|
|
14
|
+
# Returns a SHA1 hash of the normalized query
|
|
15
|
+
def generate(sql)
|
|
16
|
+
normalized = normalize(sql)
|
|
17
|
+
Digest::SHA1.hexdigest(normalized)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Normalize SQL by removing all literals and standardizing whitespace
|
|
21
|
+
def normalize(sql)
|
|
22
|
+
s = sql.to_s.dup
|
|
23
|
+
|
|
24
|
+
# Replace string literals (both single and double quotes)
|
|
25
|
+
s.gsub!(/'(?:''|[^'])*'/, "?")
|
|
26
|
+
s.gsub!(/"(?:""|[^"])*"/, "?")
|
|
27
|
+
|
|
28
|
+
# Replace numeric literals (integers and floats)
|
|
29
|
+
s.gsub!(/\b\d+\.?\d*\b/, "?")
|
|
30
|
+
|
|
31
|
+
# Replace IN (...) with IN (?)
|
|
32
|
+
s.gsub!(/IN\s*\([^)]+\)/i, "IN (?)")
|
|
33
|
+
|
|
34
|
+
# Normalize whitespace
|
|
35
|
+
s.gsub!(/\s+/, " ")
|
|
36
|
+
|
|
37
|
+
s.strip.downcase
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get or initialize the stats store for the current process
|
|
41
|
+
def stats
|
|
42
|
+
@stats ||= Hash.new do |h, fp|
|
|
43
|
+
h[fp] = {
|
|
44
|
+
count: 0,
|
|
45
|
+
total_duration_ms: 0.0,
|
|
46
|
+
min_duration_ms: Float::INFINITY,
|
|
47
|
+
max_duration_ms: 0.0,
|
|
48
|
+
first_seen_at: Time.now,
|
|
49
|
+
last_seen_at: Time.now
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Record a query execution
|
|
55
|
+
def record(sql, duration_ms)
|
|
56
|
+
fp = generate(sql)
|
|
57
|
+
stat = stats[fp]
|
|
58
|
+
|
|
59
|
+
stat[:count] += 1
|
|
60
|
+
stat[:total_duration_ms] += duration_ms
|
|
61
|
+
stat[:min_duration_ms] = [stat[:min_duration_ms], duration_ms].min
|
|
62
|
+
stat[:max_duration_ms] = [stat[:max_duration_ms], duration_ms].max
|
|
63
|
+
stat[:last_seen_at] = Time.now
|
|
64
|
+
|
|
65
|
+
fp
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get stats for a specific fingerprint
|
|
69
|
+
def stats_for(fingerprint)
|
|
70
|
+
stats[fingerprint]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get all fingerprints sorted by frequency
|
|
74
|
+
def top_by_count(limit = 10)
|
|
75
|
+
stats.sort_by { |_fp, s| -s[:count] }.first(limit).to_h
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get all fingerprints sorted by total duration
|
|
79
|
+
def top_by_duration(limit = 10)
|
|
80
|
+
stats.sort_by { |_fp, s| -s[:total_duration_ms] }.first(limit).to_h
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Get all fingerprints sorted by average duration
|
|
84
|
+
def top_by_avg_duration(limit = 10)
|
|
85
|
+
stats.sort_by { |_fp, s|
|
|
86
|
+
s[:count] > 0 ? -(s[:total_duration_ms] / s[:count]) : 0
|
|
87
|
+
}.first(limit).to_h
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Reset all stats (useful for testing)
|
|
91
|
+
def reset!
|
|
92
|
+
@stats = nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -1,24 +1,63 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "query_guard/security"
|
|
4
|
+
|
|
2
5
|
module QueryGuard
|
|
3
6
|
class Error < StandardError; end
|
|
4
7
|
|
|
5
8
|
class Middleware
|
|
9
|
+
class BodyProxy
|
|
10
|
+
def initialize(body, stats)
|
|
11
|
+
@body = body
|
|
12
|
+
@stats = stats
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def each
|
|
16
|
+
@body.each do |chunk|
|
|
17
|
+
@stats[:response_bytes] += chunk.to_s.bytesize
|
|
18
|
+
yield chunk
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def close
|
|
23
|
+
@body.close if @body.respond_to?(:close)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
6
27
|
def initialize(app, config)
|
|
7
28
|
@app = app
|
|
8
29
|
@config = config
|
|
9
30
|
end
|
|
10
31
|
|
|
11
32
|
def call(env)
|
|
12
|
-
unless @config.enabled?(rails_env)
|
|
13
|
-
return @app.call(env)
|
|
14
|
-
end
|
|
33
|
+
return @app.call(env) unless @config.enabled?(rails_env)
|
|
15
34
|
|
|
16
|
-
|
|
35
|
+
# Initialize new Context for this request
|
|
36
|
+
context = Core::Context.new
|
|
37
|
+
Thread.current[:query_guard_context] = context
|
|
38
|
+
|
|
39
|
+
# Legacy: Also initialize old stats hash for backward compatibility
|
|
40
|
+
Thread.current[:query_guard_stats] = {
|
|
41
|
+
count: 0,
|
|
42
|
+
total_duration_ms: 0.0,
|
|
43
|
+
violations: [],
|
|
44
|
+
response_bytes: 0
|
|
45
|
+
}
|
|
17
46
|
|
|
18
47
|
status, headers, body = @app.call(env)
|
|
19
|
-
|
|
20
|
-
|
|
48
|
+
|
|
49
|
+
# Wrap body to track response bytes
|
|
50
|
+
proxied_body = BodyProxy.new(body, Thread.current[:query_guard_stats])
|
|
51
|
+
|
|
52
|
+
# Run all registered analyzers
|
|
53
|
+
findings = @config.analyzer_registry.analyze(context, @config)
|
|
54
|
+
|
|
55
|
+
# Check and report findings
|
|
56
|
+
check_and_report!(context, findings)
|
|
57
|
+
|
|
58
|
+
[status, headers, proxied_body]
|
|
21
59
|
ensure
|
|
60
|
+
Thread.current[:query_guard_context] = nil
|
|
22
61
|
Thread.current[:query_guard_stats] = nil
|
|
23
62
|
end
|
|
24
63
|
|
|
@@ -40,20 +79,48 @@ module QueryGuard
|
|
|
40
79
|
end
|
|
41
80
|
end
|
|
42
81
|
|
|
43
|
-
def check_and_report!
|
|
82
|
+
def check_and_report!(context, findings)
|
|
83
|
+
# Check findings and legacy stats for backward compatibility
|
|
44
84
|
stats = Thread.current[:query_guard_stats] || { count: 0, violations: [] }
|
|
45
|
-
|
|
85
|
+
legacy_violations = stats[:violations].dup
|
|
46
86
|
|
|
47
|
-
if
|
|
48
|
-
|
|
87
|
+
return if findings.empty? && legacy_violations.empty?
|
|
88
|
+
|
|
89
|
+
# Log all findings
|
|
90
|
+
findings.each do |finding|
|
|
91
|
+
message = format_finding_message(finding)
|
|
92
|
+
logger.warn(message)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Log legacy violations for compatibility
|
|
96
|
+
legacy_violations.each do |v|
|
|
97
|
+
message = format_legacy_message(v, stats)
|
|
98
|
+
logger.warn(message)
|
|
49
99
|
end
|
|
50
100
|
|
|
51
|
-
|
|
101
|
+
# Raise if configured
|
|
102
|
+
if @config.raise_on_violation && (!findings.empty? || !legacy_violations.empty?)
|
|
103
|
+
message = format_summary_message(findings, stats)
|
|
104
|
+
raise QueryGuard::Error, message
|
|
105
|
+
end
|
|
106
|
+
end
|
|
52
107
|
|
|
53
|
-
|
|
54
|
-
|
|
108
|
+
def format_finding_message(finding)
|
|
109
|
+
"#{@config.log_prefix} [#{finding.severity.upcase}] #{finding.analyzer_name}:#{finding.rule_name} - #{finding.message}"
|
|
110
|
+
end
|
|
55
111
|
|
|
56
|
-
|
|
112
|
+
def format_legacy_message(v, stats)
|
|
113
|
+
details = case v[:type]
|
|
114
|
+
when :too_many_queries
|
|
115
|
+
"too_many_queries: count=#{v[:count]} limit=#{v[:limit]}"
|
|
116
|
+
when :slow_query
|
|
117
|
+
"slow_query: #{v[:duration_ms]}ms SQL=#{truncate_sql(v[:sql])}"
|
|
118
|
+
when :select_star
|
|
119
|
+
"select_star: SQL=#{truncate_sql(v[:sql])}"
|
|
120
|
+
else
|
|
121
|
+
v[:type].to_s
|
|
122
|
+
end
|
|
123
|
+
"#{@config.log_prefix} queries=#{stats[:count]} total_ms=#{stats[:total_duration_ms].round(2)} | #{details}"
|
|
57
124
|
end
|
|
58
125
|
|
|
59
126
|
def format_message(stats, violations)
|
|
@@ -65,12 +132,31 @@ module QueryGuard
|
|
|
65
132
|
"slow_query: #{v[:duration_ms]}ms SQL=#{truncate_sql(v[:sql])}"
|
|
66
133
|
when :select_star
|
|
67
134
|
"select_star: SQL=#{truncate_sql(v[:sql])}"
|
|
135
|
+
when :sql_injection_suspected
|
|
136
|
+
"sql_injection_suspected: SQL=#{truncate_sql(v[:sql])}"
|
|
137
|
+
when :possible_data_exfiltration_query
|
|
138
|
+
"possible_exfiltration_query: SQL=#{truncate_sql(v[:sql])}"
|
|
139
|
+
when :data_exfiltration_large_response
|
|
140
|
+
"data_exfiltration_large_response: bytes=#{v[:bytes]} limit=#{v[:limit]} path=#{v[:path]}"
|
|
141
|
+
when :data_exfiltration_suspected_export
|
|
142
|
+
"data_exfiltration_suspected_export: bytes=#{v[:bytes]} path=#{v[:path]}"
|
|
143
|
+
when :unusual_query_rate
|
|
144
|
+
"unusual_query_rate: actor=#{v[:actor]} per_min=#{v[:per_minute]} limit=#{v[:limit]}"
|
|
145
|
+
when :unusual_query_variety
|
|
146
|
+
"unusual_query_variety: actor=#{v[:actor]} uniq_fp_per_min=#{v[:unique_fingerprints_per_minute]} limit=#{v[:limit]}"
|
|
147
|
+
when :mass_assignment_unpermitted_params
|
|
148
|
+
"mass_assignment_unpermitted: keys=#{v[:keys].join(',')} sensitive=#{v[:sensitive_keys].join(',')}"
|
|
68
149
|
else
|
|
69
150
|
v[:type].to_s
|
|
70
151
|
end
|
|
71
152
|
end.join(" | ")
|
|
72
153
|
|
|
73
|
-
"#{@config.log_prefix} queries=#{stats[:count]} total_ms=#{stats[:total_duration_ms].round(2)} | #{details}"
|
|
154
|
+
"#{@config.log_prefix} queries=#{stats[:count]} total_ms=#{stats[:total_duration_ms].round(2)} resp_bytes=#{stats[:response_bytes]} | #{details}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def format_summary_message(findings, stats)
|
|
158
|
+
details = findings.map { |f| "#{f.analyzer_name}:#{f.rule_name}" }.join(", ")
|
|
159
|
+
"#{@config.log_prefix} violations: #{details}"
|
|
74
160
|
end
|
|
75
161
|
|
|
76
162
|
def truncate_sql(sql, max = 200)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Migrations
|
|
5
|
+
# Abstract interface for database metadata adapters.
|
|
6
|
+
#
|
|
7
|
+
# Adapters implement this interface to provide table metadata
|
|
8
|
+
# (row counts, lock behavior, etc.) from different database systems.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# adapter = PostgreSQLAdapter.new(connection)
|
|
12
|
+
# rows = adapter.estimate_table_rows(:users)
|
|
13
|
+
#
|
|
14
|
+
# Adapters should:
|
|
15
|
+
# 1. Handle connection failures gracefully
|
|
16
|
+
# 2. Return nil or empty hash if data unavailable
|
|
17
|
+
# 3. Cache results where possible
|
|
18
|
+
# 4. Never raise exceptions during metadata queries
|
|
19
|
+
class DatabaseAdapter
|
|
20
|
+
# Get estimated row count for a table
|
|
21
|
+
#
|
|
22
|
+
# @param table_name [String, Symbol] Name of the table
|
|
23
|
+
# @return [Integer, nil] Estimated number of rows, or nil if unavailable
|
|
24
|
+
def estimate_table_rows(table_name)
|
|
25
|
+
raise NotImplementedError, "Subclasses must implement estimate_table_rows"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get lock impact estimate for a table
|
|
29
|
+
#
|
|
30
|
+
# Scores table's lock risk based on size and activity.
|
|
31
|
+
# Based on PostgreSQL's vacuum and maintenance overhead.
|
|
32
|
+
#
|
|
33
|
+
# @param table_name [String, Symbol] Name of the table
|
|
34
|
+
# @return [Symbol, nil] Risk level: :low, :medium, :high, :critical
|
|
35
|
+
def estimate_lock_risk(table_name)
|
|
36
|
+
raise NotImplementedError, "Subclasses must implement estimate_lock_risk"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Check if table exists in database
|
|
40
|
+
#
|
|
41
|
+
# @param table_name [String, Symbol] Name of the table
|
|
42
|
+
# @return [Boolean] True if table exists
|
|
43
|
+
def table_exists?(table_name)
|
|
44
|
+
raise NotImplementedError, "Subclasses must implement table_exists?"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get all table names in current schema
|
|
48
|
+
#
|
|
49
|
+
# @return [Array<String>] List of table names
|
|
50
|
+
def list_tables
|
|
51
|
+
raise NotImplementedError, "Subclasses must implement list_tables"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Check if connection is healthy
|
|
55
|
+
#
|
|
56
|
+
# @return [Boolean] True if adapter can query database
|
|
57
|
+
def connected?
|
|
58
|
+
raise NotImplementedError, "Subclasses must implement connected?"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# NoOp adapter that always returns unavailable
|
|
63
|
+
#
|
|
64
|
+
# Used when no database connection is configured.
|
|
65
|
+
# Gracefully disables database-aware analysis.
|
|
66
|
+
class NullDatabaseAdapter < DatabaseAdapter
|
|
67
|
+
def estimate_table_rows(table_name)
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def estimate_lock_risk(table_name)
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def table_exists?(table_name)
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def list_tables
|
|
80
|
+
[]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def connected?
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Migrations
|
|
5
|
+
# Migration risk analyzer - scans Rails migration files for risky patterns.
|
|
6
|
+
# Searches for dangerous operations like:
|
|
7
|
+
# - Index additions without algorithm: :concurrently
|
|
8
|
+
# - Column removals/type changes
|
|
9
|
+
# - Non-NULL additions without defaults
|
|
10
|
+
# - Full-table updates
|
|
11
|
+
# - Unsafe raw SQL
|
|
12
|
+
#
|
|
13
|
+
# Optionally integrates with database metadata for table-size-aware risk estimation.
|
|
14
|
+
#
|
|
15
|
+
# Example:
|
|
16
|
+
# analyzer = MigrationAnalyzer.new
|
|
17
|
+
# findings = analyzer.analyze_migration(migration_file_path)
|
|
18
|
+
#
|
|
19
|
+
# With table size awareness:
|
|
20
|
+
# connection = ActiveRecord::Base.connection
|
|
21
|
+
# adapter = PostgreSQLAdapter.new(connection)
|
|
22
|
+
# analyzer = MigrationAnalyzer.new(database_adapter: adapter)
|
|
23
|
+
# findings = analyzer.analyze_migration(migration_file_path)
|
|
24
|
+
class MigrationAnalyzer < Analyzers::Base
|
|
25
|
+
# Initialize analyzer
|
|
26
|
+
#
|
|
27
|
+
# @param database_adapter [DatabaseAdapter] Optional adapter for table metadata
|
|
28
|
+
def initialize(database_adapter: nil)
|
|
29
|
+
super(:migration_risk)
|
|
30
|
+
@database_adapter = database_adapter
|
|
31
|
+
@table_risk_analyzer = TableRiskAnalyzer.new(database_adapter)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Analyze a single migration file
|
|
35
|
+
#
|
|
36
|
+
# @param file_path [String] Path to migration file
|
|
37
|
+
# @return [Array<Core::Finding>] List of findings
|
|
38
|
+
def analyze_migration(file_path)
|
|
39
|
+
return [] unless File.exist?(file_path)
|
|
40
|
+
|
|
41
|
+
content = File.read(file_path)
|
|
42
|
+
migration_name = File.basename(file_path, ".rb")
|
|
43
|
+
|
|
44
|
+
risks = MigrationRiskDetectors.detect_risks(content, migration_name)
|
|
45
|
+
|
|
46
|
+
# Enhance risks with table metadata if adapter available
|
|
47
|
+
risks = @table_risk_analyzer.enhance_risks(content, risks)
|
|
48
|
+
|
|
49
|
+
risks_to_findings(risks, file_path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Analyze all migrations in a directory
|
|
53
|
+
#
|
|
54
|
+
# @param migrations_dir [String] Path to db/migrate directory
|
|
55
|
+
# @return [Array<Core::Finding>] List of findings from all migrations
|
|
56
|
+
def analyze_migrations_directory(migrations_dir = "db/migrate")
|
|
57
|
+
findings = []
|
|
58
|
+
return findings unless Dir.exist?(migrations_dir)
|
|
59
|
+
|
|
60
|
+
Dir.glob(File.join(migrations_dir, "*.rb")).each do |file_path|
|
|
61
|
+
findings.concat(analyze_migration(file_path))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
findings
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Analyze method for integration with QueryGuard context
|
|
68
|
+
# This is called by registry when running full analysis
|
|
69
|
+
#
|
|
70
|
+
# @param context [Core::Context] Not used for migrations, but required by Base
|
|
71
|
+
# @param config [Config] Configuration object
|
|
72
|
+
# @return [Array<Core::Finding>] List of findings
|
|
73
|
+
def analyze(context, config)
|
|
74
|
+
migrations_dir = config&.migrations_directory || "db/migrate"
|
|
75
|
+
analyze_migrations_directory(migrations_dir)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def risks_to_findings(risks, file_path)
|
|
81
|
+
risks.map do |risk|
|
|
82
|
+
severity = risk[:severity] || :warn
|
|
83
|
+
|
|
84
|
+
Core::FindingBuilders.build(
|
|
85
|
+
analyzer_name: name,
|
|
86
|
+
rule_name: risk[:type],
|
|
87
|
+
severity: severity,
|
|
88
|
+
title: risk[:title],
|
|
89
|
+
description: risk[:description],
|
|
90
|
+
message: risk[:message],
|
|
91
|
+
file_path: file_path,
|
|
92
|
+
line_number: risk[:line_number],
|
|
93
|
+
recommendations: [risk[:recommendation]],
|
|
94
|
+
metadata: risk[:metadata]
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|