query_guard 0.4.2 → 0.5.1
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 +390 -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,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Detects SELECT * statements.
|
|
6
|
+
class SelectStarAnalyzer < Base
|
|
7
|
+
SELECT_STAR_PATTERN = /\bSELECT\s+\*/i.freeze
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
super(:select_star)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def analyze(context, config)
|
|
14
|
+
findings = []
|
|
15
|
+
|
|
16
|
+
return findings unless config.block_select_star
|
|
17
|
+
|
|
18
|
+
context.queries.each do |query|
|
|
19
|
+
next unless matches_select_star?(query.sql)
|
|
20
|
+
next if exceeds_ignored_sql?(query.sql, config)
|
|
21
|
+
|
|
22
|
+
findings << Core::FindingBuilders.select_star(
|
|
23
|
+
query,
|
|
24
|
+
severity: config.select_star_severity || :warn
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
findings
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def matches_select_star?(sql)
|
|
34
|
+
SELECT_STAR_PATTERN.match?(sql)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def exceeds_ignored_sql?(sql, config)
|
|
38
|
+
config.ignored_sql.any? { |pattern| pattern === sql }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Detects slow queries based on duration threshold.
|
|
6
|
+
class SlowQueryAnalyzer < Base
|
|
7
|
+
def initialize
|
|
8
|
+
super(:slow_query)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def analyze(context, config)
|
|
12
|
+
findings = []
|
|
13
|
+
threshold = config.max_duration_ms_per_query
|
|
14
|
+
|
|
15
|
+
return findings if threshold.nil?
|
|
16
|
+
|
|
17
|
+
context.queries.each do |query|
|
|
18
|
+
next if exceeds_ignored_sql?(query.sql, config)
|
|
19
|
+
next unless query.duration_ms > threshold
|
|
20
|
+
|
|
21
|
+
findings << Core::FindingBuilders.slow_query(
|
|
22
|
+
query,
|
|
23
|
+
duration_ms: query.duration_ms,
|
|
24
|
+
threshold_ms: threshold,
|
|
25
|
+
severity: config.slow_query_severity || :warn
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
findings
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def exceeds_ignored_sql?(sql, config)
|
|
35
|
+
config.ignored_sql.any? { |pattern| pattern === sql }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
# Budget system for enforcing query SLOs on controllers and jobs.
|
|
5
|
+
# Supports modes: :log (warn only), :notify (callback), :raise (exception).
|
|
6
|
+
class Budget
|
|
7
|
+
class Violation < StandardError; end
|
|
8
|
+
|
|
9
|
+
attr_reader :rules, :mode, :on_violation
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@rules = {}
|
|
13
|
+
@mode = :log
|
|
14
|
+
@on_violation = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# DSL: Define a budget for a specific controller action.
|
|
18
|
+
# Examples:
|
|
19
|
+
# budget.for("users#index", count: 10, duration_ms: 500)
|
|
20
|
+
# budget.for("posts#show", count: 5)
|
|
21
|
+
def for(key, **limits)
|
|
22
|
+
normalized = normalize_key(key)
|
|
23
|
+
@rules[normalized] ||= {}
|
|
24
|
+
@rules[normalized].merge!(limits)
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# DSL: Define a budget for a background job.
|
|
29
|
+
# Examples:
|
|
30
|
+
# budget.for_job("EmailJob", count: 50, duration_ms: 2000)
|
|
31
|
+
# budget.for_job(EmailJob, count: 50)
|
|
32
|
+
def for_job(job_class_or_name, **limits)
|
|
33
|
+
key = job_class_or_name.is_a?(String) ? job_class_or_name : job_class_or_name.to_s
|
|
34
|
+
normalized = normalize_key("job:#{key}")
|
|
35
|
+
@rules[normalized] ||= {}
|
|
36
|
+
@rules[normalized].merge!(limits)
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Set the enforcement mode
|
|
41
|
+
# :log - Log warnings only (default)
|
|
42
|
+
# :notify - Call on_violation callback
|
|
43
|
+
# :raise - Raise Budget::Violation exception
|
|
44
|
+
def mode=(value)
|
|
45
|
+
unless [:log, :notify, :raise].include?(value)
|
|
46
|
+
raise ArgumentError, "Invalid mode: #{value}. Must be :log, :notify, or :raise"
|
|
47
|
+
end
|
|
48
|
+
@mode = value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Set a callback for :notify mode
|
|
52
|
+
def on_violation=(callback)
|
|
53
|
+
unless callback.respond_to?(:call)
|
|
54
|
+
raise ArgumentError, "on_violation must be callable"
|
|
55
|
+
end
|
|
56
|
+
@on_violation = callback
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if a budget exists for the given key
|
|
60
|
+
def budget_for(key)
|
|
61
|
+
normalized = normalize_key(key)
|
|
62
|
+
@rules[normalized]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check stats against budget and return violations
|
|
66
|
+
def check(key, stats)
|
|
67
|
+
budget = budget_for(key)
|
|
68
|
+
return [] unless budget
|
|
69
|
+
|
|
70
|
+
violations = []
|
|
71
|
+
|
|
72
|
+
if budget[:count] && stats[:count] > budget[:count]
|
|
73
|
+
violations << {
|
|
74
|
+
type: :budget_exceeded_count,
|
|
75
|
+
key: key,
|
|
76
|
+
actual: stats[:count],
|
|
77
|
+
limit: budget[:count]
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if budget[:duration_ms] && stats[:total_duration_ms] > budget[:duration_ms]
|
|
82
|
+
violations << {
|
|
83
|
+
type: :budget_exceeded_duration,
|
|
84
|
+
key: key,
|
|
85
|
+
actual: stats[:total_duration_ms],
|
|
86
|
+
limit: budget[:duration_ms]
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
violations
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Enforce budget violations according to current mode
|
|
94
|
+
def enforce!(key, violations)
|
|
95
|
+
return if violations.empty?
|
|
96
|
+
|
|
97
|
+
case @mode
|
|
98
|
+
when :log
|
|
99
|
+
log_violations(key, violations)
|
|
100
|
+
when :notify
|
|
101
|
+
notify_violations(key, violations)
|
|
102
|
+
when :raise
|
|
103
|
+
raise_violations(key, violations)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def normalize_key(key)
|
|
110
|
+
key.to_s.downcase.gsub(/\s+/, "")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def log_violations(key, violations)
|
|
114
|
+
violations.each do |v|
|
|
115
|
+
message = format_violation(key, v)
|
|
116
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
117
|
+
Rails.logger.warn("[QueryGuard::Budget] #{message}")
|
|
118
|
+
else
|
|
119
|
+
warn("[QueryGuard::Budget] #{message}")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def notify_violations(key, violations)
|
|
125
|
+
return unless @on_violation
|
|
126
|
+
|
|
127
|
+
violations.each do |v|
|
|
128
|
+
@on_violation.call(key, v)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def raise_violations(key, violations)
|
|
133
|
+
messages = violations.map { |v| format_violation(key, v) }
|
|
134
|
+
raise Violation, messages.join("; ")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def format_violation(key, v)
|
|
138
|
+
case v[:type]
|
|
139
|
+
when :budget_exceeded_count
|
|
140
|
+
"#{key}: Query count exceeded (#{v[:actual]} > #{v[:limit]})"
|
|
141
|
+
when :budget_exceeded_duration
|
|
142
|
+
"#{key}: Total duration exceeded (#{v[:actual].round(2)}ms > #{v[:limit]}ms)"
|
|
143
|
+
else
|
|
144
|
+
"#{key}: #{v[:type]}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module QueryGuard
|
|
7
|
+
class CLI
|
|
8
|
+
# Formats multiple QueryGuard reports into a single batch request.
|
|
9
|
+
#
|
|
10
|
+
# Used for bulk ingestion into SaaS platforms. Supports:
|
|
11
|
+
# - Multiple reports in a single API request
|
|
12
|
+
# - Batch correlation IDs for tracking across the entire batch
|
|
13
|
+
# - Per-report tracing for individual finding tracking
|
|
14
|
+
# - Pagination metadata for large batches
|
|
15
|
+
#
|
|
16
|
+
# Example:
|
|
17
|
+
# reports = [
|
|
18
|
+
# { report_version: '1.0', findings: [...], ... },
|
|
19
|
+
# { report_version: '1.0', findings: [...], ... }
|
|
20
|
+
# ]
|
|
21
|
+
#
|
|
22
|
+
# batch = BatchReportFormatter.new(
|
|
23
|
+
# reports: reports,
|
|
24
|
+
# batch_id: 'batch-123',
|
|
25
|
+
# correlation_id: 'api-request-456'
|
|
26
|
+
# )
|
|
27
|
+
#
|
|
28
|
+
# json_output = batch.generate
|
|
29
|
+
class BatchReportFormatter
|
|
30
|
+
BATCH_SCHEMA_VERSION = '1.0'
|
|
31
|
+
BATCH_TYPE = 'batch'
|
|
32
|
+
|
|
33
|
+
def initialize(reports:, batch_id: nil, correlation_id: nil, options: {})
|
|
34
|
+
@reports = Array(reports)
|
|
35
|
+
@batch_id = batch_id || generate_batch_id
|
|
36
|
+
@correlation_id = correlation_id || generate_correlation_id
|
|
37
|
+
@options = options
|
|
38
|
+
@created_at = Time.now
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Generate the complete batch JSON
|
|
42
|
+
# Returns: String (JSON)
|
|
43
|
+
def generate
|
|
44
|
+
JSON.pretty_generate(build_batch)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Build batch structure and return as hash
|
|
48
|
+
def build_batch
|
|
49
|
+
{
|
|
50
|
+
batch_version: BATCH_SCHEMA_VERSION,
|
|
51
|
+
batch_type: BATCH_TYPE,
|
|
52
|
+
batch_id: @batch_id,
|
|
53
|
+
correlation_id: @correlation_id,
|
|
54
|
+
created_at: @created_at.utc.iso8601.gsub('+00:00', 'Z'),
|
|
55
|
+
report_count: @reports.length,
|
|
56
|
+
reports: @reports,
|
|
57
|
+
pagination: build_pagination,
|
|
58
|
+
stats: build_batch_stats
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Pagination metadata for large batches
|
|
65
|
+
# Useful when records exceed size limits (e.g., >10MB)
|
|
66
|
+
def build_pagination
|
|
67
|
+
pagination = {
|
|
68
|
+
total_reports: @reports.length,
|
|
69
|
+
page: @options[:page] || 1,
|
|
70
|
+
page_size: @options[:page_size] || @reports.length,
|
|
71
|
+
has_more: @options[:has_more] || false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Include continuation token if provided (for cursor-based pagination)
|
|
75
|
+
pagination[:continuation_token] = @options[:continuation_token] if @options[:continuation_token]
|
|
76
|
+
pagination[:next_page_token] = @options[:next_page_token] if @options[:next_page_token]
|
|
77
|
+
|
|
78
|
+
pagination
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Aggregate statistics across all reports in the batch
|
|
82
|
+
def build_batch_stats
|
|
83
|
+
total_findings = 0
|
|
84
|
+
total_critical = 0
|
|
85
|
+
total_error = 0
|
|
86
|
+
total_warn = 0
|
|
87
|
+
total_info = 0
|
|
88
|
+
by_report_type = { 'analyze' => 0, 'check' => 0 }
|
|
89
|
+
|
|
90
|
+
@reports.each do |report|
|
|
91
|
+
summary = report[:summary] || {}
|
|
92
|
+
total_findings += summary[:total_findings] || 0
|
|
93
|
+
total_critical += summary[:by_severity]&.dig(:critical) || 0
|
|
94
|
+
total_error += summary[:by_severity]&.dig(:error) || 0
|
|
95
|
+
total_warn += summary[:by_severity]&.dig(:warn) || 0
|
|
96
|
+
total_info += summary[:by_severity]&.dig(:info) || 0
|
|
97
|
+
|
|
98
|
+
report_type = report[:report_type] || 'unknown'
|
|
99
|
+
by_report_type[report_type] ||= 0
|
|
100
|
+
by_report_type[report_type] += 1
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
total_findings: total_findings,
|
|
105
|
+
findings_by_severity: {
|
|
106
|
+
critical: total_critical,
|
|
107
|
+
error: total_error,
|
|
108
|
+
warn: total_warn,
|
|
109
|
+
info: total_info
|
|
110
|
+
},
|
|
111
|
+
reports_by_type: by_report_type,
|
|
112
|
+
processing_time_ms: ((@created_at - @created_at) * 1000).round(2)
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Helper: Generate a unique batch ID
|
|
117
|
+
def generate_batch_id
|
|
118
|
+
require 'securerandom'
|
|
119
|
+
"batch-#{SecureRandom.hex(8)}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Helper: Generate a correlation ID for the entire batch
|
|
123
|
+
def generate_correlation_id
|
|
124
|
+
require 'securerandom'
|
|
125
|
+
"corr-#{SecureRandom.hex(8)}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
class CLI
|
|
5
|
+
# Base class for CLI commands
|
|
6
|
+
class Command
|
|
7
|
+
def initialize(path, options = {})
|
|
8
|
+
@path = path
|
|
9
|
+
@options = options
|
|
10
|
+
@formatter = Formatter.new(options)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
protected
|
|
14
|
+
|
|
15
|
+
def find_migration_files(path)
|
|
16
|
+
pattern = File.join(path, '**', '*_*.rb')
|
|
17
|
+
Dir.glob(pattern)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def find_ruby_files(path)
|
|
21
|
+
pattern = File.join(path, '**', '*.rb')
|
|
22
|
+
Dir.glob(pattern)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def analyze_migrations(files)
|
|
26
|
+
analyzer = QueryGuard::Migrations::MigrationAnalyzer.new(
|
|
27
|
+
database_adapter: get_database_adapter
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
findings = []
|
|
31
|
+
files.each do |file|
|
|
32
|
+
begin
|
|
33
|
+
file_findings = analyzer.analyze_migration(file)
|
|
34
|
+
file_findings.each do |finding|
|
|
35
|
+
finding[:file] = file
|
|
36
|
+
findings << finding
|
|
37
|
+
end
|
|
38
|
+
rescue => e
|
|
39
|
+
puts "Warning: Failed to analyze #{file}: #{e.message}" if @options[:verbose]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
findings
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def get_database_adapter
|
|
47
|
+
# Try to get a live database adapter if available
|
|
48
|
+
return nil unless defined?(ActiveRecord) && ActiveRecord::Base.connected?
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
QueryGuard::Migrations::PostgreSQLAdapter.new(
|
|
52
|
+
connection: ActiveRecord::Base.connection
|
|
53
|
+
)
|
|
54
|
+
rescue StandardError
|
|
55
|
+
nil # Fall back to NullDatabaseAdapter
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def severity_to_number(severity)
|
|
60
|
+
case severity
|
|
61
|
+
when :critical then 4
|
|
62
|
+
when :error then 3
|
|
63
|
+
when :warn then 2
|
|
64
|
+
when :info then 1
|
|
65
|
+
else 0
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def threshold_to_number(threshold)
|
|
70
|
+
case threshold&.to_s&.downcase
|
|
71
|
+
when 'critical' then 4
|
|
72
|
+
when 'error' then 3
|
|
73
|
+
when 'warn' then 2
|
|
74
|
+
when 'info' then 1
|
|
75
|
+
else 2 # Default to warn
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def findings_exceed_threshold?(findings, threshold)
|
|
80
|
+
threshold_num = threshold_to_number(threshold)
|
|
81
|
+
findings.any? { |f| severity_to_number(f[:severity]) >= threshold_num }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def path_exists?
|
|
85
|
+
File.exist?(@path)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def path_absolute
|
|
89
|
+
File.expand_path(@path)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
class CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# Analyzes queries and migrations, printing detailed results
|
|
7
|
+
class Analyze < Command
|
|
8
|
+
def execute
|
|
9
|
+
unless path_exists?
|
|
10
|
+
puts "Error: Path does not exist: #{path_absolute}"
|
|
11
|
+
exit 1
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Skip progress output if JSON format is being used
|
|
15
|
+
unless @options[:json] || @options[:format] == 'json'
|
|
16
|
+
puts "Analyzing: #{path_absolute}"
|
|
17
|
+
|
|
18
|
+
# Show database context
|
|
19
|
+
adapter = get_database_adapter
|
|
20
|
+
if adapter
|
|
21
|
+
puts "Database context: Connected (accurate severity)\n"
|
|
22
|
+
else
|
|
23
|
+
puts "Database context: Not available (conservative estimates)"
|
|
24
|
+
puts " For more accurate results, run: DATABASE_URL=... queryguard analyze\n"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
puts "(This may take a moment...)\n"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Find and analyze migration files
|
|
31
|
+
migration_files = find_migration_files(@path)
|
|
32
|
+
if migration_files.any?
|
|
33
|
+
puts " Found #{migration_files.length} migration files" unless @options[:json] || @options[:format] == 'json'
|
|
34
|
+
findings = analyze_migrations(migration_files)
|
|
35
|
+
else
|
|
36
|
+
findings = []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Format and print results
|
|
40
|
+
if findings.any?
|
|
41
|
+
@formatter.print_findings(findings, "Migration Risk Analysis Results", 'analyze', @path)
|
|
42
|
+
else
|
|
43
|
+
puts "\n✅ No issues found in migrations!\n" unless @options[:json] || @options[:format] == 'json'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Exit with success (analyze doesn't fail on findings)
|
|
47
|
+
0
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
class CLI
|
|
5
|
+
module Commands
|
|
6
|
+
# Checks if findings exceed severity threshold for CI/CD
|
|
7
|
+
class Check < Command
|
|
8
|
+
def execute
|
|
9
|
+
unless path_exists?
|
|
10
|
+
puts "Error: Path does not exist: #{path_absolute}"
|
|
11
|
+
return 2 # Failed to check
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
threshold = @options[:threshold] || 'error'
|
|
15
|
+
|
|
16
|
+
# Skip progress output if JSON format is being used
|
|
17
|
+
unless @options[:json] || @options[:format] == 'json'
|
|
18
|
+
puts "Checking migrations: #{path_absolute}"
|
|
19
|
+
puts "Threshold: #{threshold.upcase}\n"
|
|
20
|
+
puts "(This may take a moment...)\n"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Find and analyze migration files
|
|
24
|
+
migration_files = find_migration_files(@path)
|
|
25
|
+
if migration_files.empty?
|
|
26
|
+
puts "✅ No migrations found - clear to deploy!\n" unless @options[:json] || @options[:format] == 'json'
|
|
27
|
+
return 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
puts " Found #{migration_files.length} migration files" unless @options[:json] || @options[:format] == 'json'
|
|
31
|
+
findings = analyze_migrations(migration_files)
|
|
32
|
+
|
|
33
|
+
if findings.empty?
|
|
34
|
+
puts "\n✅ No issues found - clear to deploy!\n" unless @options[:json] || @options[:format] == 'json'
|
|
35
|
+
return 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check if findings exceed threshold
|
|
39
|
+
if findings_exceed_threshold?(findings, threshold)
|
|
40
|
+
@formatter.print_findings(findings, "Migration Risk Check - FAILED ❌", 'check', @path)
|
|
41
|
+
unless @options[:json] || @options[:format] == 'json'
|
|
42
|
+
puts "\n🚫 Risk threshold exceeded! Deployment blocked.\n"
|
|
43
|
+
puts "To proceed, either:"
|
|
44
|
+
puts " 1. Fix the identified risks above"
|
|
45
|
+
puts " 2. Increase the threshold (e.g., --threshold error)"
|
|
46
|
+
puts " 3. Review with your DBA\n"
|
|
47
|
+
end
|
|
48
|
+
return 1 # Check failed
|
|
49
|
+
else
|
|
50
|
+
@formatter.print_findings(findings, "Migration Risk Check - PASSED ✅", 'check', @path)
|
|
51
|
+
puts "\n✅ All risks below threshold - clear to deploy!\n" unless @options[:json] || @options[:format] == 'json'
|
|
52
|
+
return 0
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|