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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/DESIGN.md +420 -0
  4. data/INDEX.md +309 -0
  5. data/README.md +579 -30
  6. data/exe/queryguard +23 -0
  7. data/lib/query_guard/action_controller_subscriber.rb +27 -0
  8. data/lib/query_guard/analysis/query_risk_classifier.rb +124 -0
  9. data/lib/query_guard/analysis/risk_detectors.rb +258 -0
  10. data/lib/query_guard/analysis/risk_level.rb +35 -0
  11. data/lib/query_guard/analyzers/base.rb +30 -0
  12. data/lib/query_guard/analyzers/query_count_analyzer.rb +31 -0
  13. data/lib/query_guard/analyzers/query_risk_analyzer.rb +146 -0
  14. data/lib/query_guard/analyzers/registry.rb +57 -0
  15. data/lib/query_guard/analyzers/select_star_analyzer.rb +42 -0
  16. data/lib/query_guard/analyzers/slow_query_analyzer.rb +39 -0
  17. data/lib/query_guard/budget.rb +148 -0
  18. data/lib/query_guard/cli/batch_report_formatter.rb +129 -0
  19. data/lib/query_guard/cli/command.rb +93 -0
  20. data/lib/query_guard/cli/commands/analyze.rb +52 -0
  21. data/lib/query_guard/cli/commands/check.rb +58 -0
  22. data/lib/query_guard/cli/formatter.rb +278 -0
  23. data/lib/query_guard/cli/json_reporter.rb +247 -0
  24. data/lib/query_guard/cli/paged_report_formatter.rb +137 -0
  25. data/lib/query_guard/cli/source_metadata_collector.rb +297 -0
  26. data/lib/query_guard/cli.rb +197 -0
  27. data/lib/query_guard/client.rb +4 -6
  28. data/lib/query_guard/config.rb +145 -6
  29. data/lib/query_guard/core/context.rb +80 -0
  30. data/lib/query_guard/core/finding.rb +162 -0
  31. data/lib/query_guard/core/finding_builders.rb +152 -0
  32. data/lib/query_guard/core/query.rb +40 -0
  33. data/lib/query_guard/explain/adapter_interface.rb +89 -0
  34. data/lib/query_guard/explain/explain_enricher.rb +367 -0
  35. data/lib/query_guard/explain/plan_signals.rb +385 -0
  36. data/lib/query_guard/explain/postgresql_adapter.rb +208 -0
  37. data/lib/query_guard/exporter.rb +124 -0
  38. data/lib/query_guard/fingerprint.rb +96 -0
  39. data/lib/query_guard/middleware.rb +101 -15
  40. data/lib/query_guard/migrations/database_adapter.rb +88 -0
  41. data/lib/query_guard/migrations/migration_analyzer.rb +100 -0
  42. data/lib/query_guard/migrations/migration_risk_detectors.rb +390 -0
  43. data/lib/query_guard/migrations/postgresql_adapter.rb +157 -0
  44. data/lib/query_guard/migrations/table_risk_analyzer.rb +154 -0
  45. data/lib/query_guard/migrations/table_size_resolver.rb +152 -0
  46. data/lib/query_guard/publish.rb +38 -0
  47. data/lib/query_guard/rspec.rb +119 -0
  48. data/lib/query_guard/security.rb +99 -0
  49. data/lib/query_guard/store.rb +38 -0
  50. data/lib/query_guard/subscriber.rb +46 -15
  51. data/lib/query_guard/suggest/index_suggester.rb +176 -0
  52. data/lib/query_guard/suggest/pattern_extractors.rb +137 -0
  53. data/lib/query_guard/trace.rb +106 -0
  54. data/lib/query_guard/uploader/http_uploader.rb +166 -0
  55. data/lib/query_guard/uploader/interface.rb +79 -0
  56. data/lib/query_guard/uploader/no_op_uploader.rb +46 -0
  57. data/lib/query_guard/uploader/registry.rb +37 -0
  58. data/lib/query_guard/uploader/upload_service.rb +80 -0
  59. data/lib/query_guard/version.rb +1 -1
  60. data/lib/query_guard.rb +54 -7
  61. metadata +78 -10
  62. data/.rspec +0 -3
  63. data/Rakefile +0 -21
  64. data/config/initializers/query_guard.rb +0 -9
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ class CLI
5
+ # Collects source and CI environment metadata for SaaS dashboards and PR views.
6
+ #
7
+ # Captures:
8
+ # - Git information (SHA, branch, repository name)
9
+ # - CI provider detection (GitHub Actions, CircleCI, Jenkins, etc.)
10
+ # - Pull request information when available
11
+ # - Graceful fallbacks for local development
12
+ #
13
+ # Design:
14
+ # - Modular detection for each CI provider
15
+ # - Fails gracefully (returns what's available)
16
+ # - No external dependencies (pure stdlib)
17
+ # - Suitable for SaaS platform ingestion
18
+ #
19
+ # Example:
20
+ # collector = QueryGuard::CLI::SourceMetadataCollector.new
21
+ # metadata = collector.collect
22
+ # # => { git: { sha: '...' }, ci: { provider: 'github_actions', ... } }
23
+ class SourceMetadataCollector
24
+ def initialize
25
+ @env = ENV.to_h
26
+ end
27
+
28
+ # Collect all available source and CI metadata
29
+ # Returns: Hash with git and ci information
30
+ def collect
31
+ {
32
+ git: collect_git_metadata,
33
+ ci: collect_ci_metadata
34
+ }.compact
35
+ end
36
+
37
+ private
38
+
39
+ # Collect git information
40
+ # SHA, branch, repository name
41
+ def collect_git_metadata
42
+ git_info = {}
43
+
44
+ # Git SHA (commit hash)
45
+ git_info[:sha] = read_git_sha
46
+ git_info[:branch] = read_git_branch
47
+ git_info[:repository] = read_git_repository
48
+
49
+ # Only include if we found at least the SHA
50
+ git_info.empty? ? nil : git_info.compact
51
+ end
52
+
53
+ # Detect CI provider and collect CI-specific metadata
54
+ def collect_ci_metadata
55
+ case ci_provider
56
+ when :github_actions
57
+ detect_github_actions_metadata
58
+ when :circle_ci
59
+ detect_circle_ci_metadata
60
+ when :jenkins
61
+ detect_jenkins_metadata
62
+ when :gitlab_ci
63
+ detect_gitlab_ci_metadata
64
+ when :travis_ci
65
+ detect_travis_ci_metadata
66
+ when :bitbucket_ci
67
+ detect_bitbucket_ci_metadata
68
+ else
69
+ # Local development or unknown CI
70
+ nil
71
+ end
72
+ end
73
+
74
+ # Detect which CI provider is running
75
+ def ci_provider
76
+ return :github_actions if @env['GITHUB_ACTIONS'] == 'true'
77
+ return :circle_ci if @env['CIRCLECI'] == 'true'
78
+ return :jenkins if @env['JENKINS_HOME']
79
+ return :gitlab_ci if @env['GITLAB_CI'] == 'true'
80
+ return :travis_ci if @env['TRAVIS'] == 'true'
81
+ return :bitbucket_ci if @env['BITBUCKET_BUILD_NUMBER']
82
+
83
+ nil
84
+ end
85
+
86
+ # Github Actions metadata
87
+ def detect_github_actions_metadata
88
+ metadata = {
89
+ provider: 'github_actions',
90
+ ci: true
91
+ }
92
+
93
+ # Repository
94
+ if @env['GITHUB_REPOSITORY']
95
+ owner, repo = @env['GITHUB_REPOSITORY'].split('/')
96
+ metadata[:repository_owner] = owner
97
+ metadata[:repository_name] = repo
98
+ end
99
+
100
+ # Pull request information
101
+ if @env['GITHUB_EVENT_NAME'] == 'pull_request'
102
+ metadata[:pull_request_number] = @env['GITHUB_REF']&.match(/refs\/pull\/(\d+)\/merge/)&.captures&.first&.to_i
103
+ metadata[:pull_request] = true
104
+ elsif @env['GITHUB_REF']
105
+ metadata[:branch] = @env['GITHUB_REF'].sub('refs/heads/', '')
106
+ end
107
+
108
+ # Run information
109
+ metadata[:run_id] = @env['GITHUB_RUN_ID']
110
+ metadata[:run_number] = @env['GITHUB_RUN_NUMBER']
111
+ metadata[:actor] = @env['GITHUB_ACTOR']
112
+ metadata[:workflow] = @env['GITHUB_WORKFLOW']
113
+
114
+ metadata.compact
115
+ end
116
+
117
+ # CircleCI metadata
118
+ def detect_circle_ci_metadata
119
+ metadata = {
120
+ provider: 'circle_ci',
121
+ ci: true
122
+ }
123
+
124
+ # Repository
125
+ metadata[:repository_owner] = @env['CIRCLE_PROJECT_USERNAME']
126
+ metadata[:repository_name] = @env['CIRCLE_PROJECT_REPONAME']
127
+
128
+ # Branch
129
+ metadata[:branch] = @env['CIRCLE_BRANCH']
130
+
131
+ # Pull request information
132
+ if @env['CIRCLE_PULL_REQUEST']
133
+ metadata[:pull_request_number] = @env['CIRCLE_PULL_REQUEST'].match(/\/(\d+)$/)&.captures&.first&.to_i
134
+ metadata[:pull_request] = true
135
+ end
136
+
137
+ # Run information
138
+ metadata[:build_number] = @env['CIRCLE_BUILD_NUM']
139
+ metadata[:job_number] = @env['CIRCLE_JOB']
140
+
141
+ metadata.compact
142
+ end
143
+
144
+ # Jenkins metadata
145
+ def detect_jenkins_metadata
146
+ metadata = {
147
+ provider: 'jenkins',
148
+ ci: true
149
+ }
150
+
151
+ # Repository and branch
152
+ metadata[:repository_url] = @env['GIT_URL']
153
+ metadata[:branch] = @env['GIT_BRANCH']&.sub('origin/', '')
154
+
155
+ # Pull request information (from GitHub Plugin)
156
+ if @env['ghprbPullId']
157
+ metadata[:pull_request_number] = @env['ghprbPullId'].to_i
158
+ metadata[:pull_request] = true
159
+ end
160
+
161
+ # Build information
162
+ metadata[:build_number] = @env['BUILD_NUMBER']
163
+ metadata[:build_id] = @env['BUILD_ID']
164
+ metadata[:job_name] = @env['JOB_NAME']
165
+
166
+ metadata.compact
167
+ end
168
+
169
+ # GitLab CI metadata
170
+ def detect_gitlab_ci_metadata
171
+ metadata = {
172
+ provider: 'gitlab_ci',
173
+ ci: true
174
+ }
175
+
176
+ # Repository
177
+ metadata[:repository_url] = @env['CI_PROJECT_URL']
178
+ metadata[:repository_name] = @env['CI_PROJECT_NAME']
179
+
180
+ # Branch and merge request
181
+ if @env['CI_MERGE_REQUEST_IID']
182
+ metadata[:pull_request_number] = @env['CI_MERGE_REQUEST_IID'].to_i
183
+ metadata[:pull_request] = true
184
+ else
185
+ metadata[:branch] = @env['CI_COMMIT_BRANCH']
186
+ end
187
+
188
+ # Pipeline information
189
+ metadata[:pipeline_id] = @env['CI_PIPELINE_ID']
190
+ metadata[:job_name] = @env['CI_JOB_NAME']
191
+
192
+ metadata.compact
193
+ end
194
+
195
+ # Travis CI metadata
196
+ def detect_travis_ci_metadata
197
+ metadata = {
198
+ provider: 'travis_ci',
199
+ ci: true
200
+ }
201
+
202
+ # Repository
203
+ metadata[:repository] = @env['TRAVIS_REPO_SLUG']
204
+
205
+ # Branch and pull request
206
+ if @env['TRAVIS_PULL_REQUEST'] && @env['TRAVIS_PULL_REQUEST'] != 'false'
207
+ metadata[:pull_request_number] = @env['TRAVIS_PULL_REQUEST'].to_i
208
+ metadata[:pull_request] = true
209
+ else
210
+ metadata[:branch] = @env['TRAVIS_BRANCH']
211
+ end
212
+
213
+ # Build information
214
+ metadata[:build_number] = @env['TRAVIS_BUILD_NUMBER']
215
+ metadata[:build_id] = @env['TRAVIS_BUILD_ID']
216
+
217
+ metadata.compact
218
+ end
219
+
220
+ # Bitbucket Pipelines metadata
221
+ def detect_bitbucket_ci_metadata
222
+ metadata = {
223
+ provider: 'bitbucket_ci',
224
+ ci: true
225
+ }
226
+
227
+ # Repository
228
+ metadata[:repository_full_name] = @env['BITBUCKET_REPO_FULL_NAME']
229
+
230
+ # Branch
231
+ metadata[:branch] = @env['BITBUCKET_BRANCH']
232
+
233
+ # Pull request information
234
+ if @env['BITBUCKET_PR_ID']
235
+ metadata[:pull_request_number] = @env['BITBUCKET_PR_ID'].to_i
236
+ metadata[:pull_request] = true
237
+ end
238
+
239
+ # Build information
240
+ metadata[:build_number] = @env['BITBUCKET_BUILD_NUMBER']
241
+
242
+ metadata.compact
243
+ end
244
+
245
+ # Read git SHA (commit hash)
246
+ # Returns: String (40-char hex) or nil
247
+ def read_git_sha
248
+ read_git_config('rev-parse', 'HEAD')
249
+ end
250
+
251
+ # Read current git branch
252
+ # Returns: String branch name or nil
253
+ def read_git_branch
254
+ # Try to get from detached HEAD first
255
+ ref = read_git_config('symbolic-ref', '--short', 'HEAD')
256
+ return ref if ref
257
+
258
+ # Fallback: try to get from rev-parse
259
+ read_git_config('rev-parse', '--abbrev-ref', 'HEAD')
260
+ end
261
+
262
+ # Read repository name from git remote
263
+ # Returns: String repository name or nil
264
+ def read_git_repository
265
+ url = read_git_config('remote', 'get-url', 'origin')
266
+ return nil unless url
267
+
268
+ # Extract repo name from URL
269
+ # Handles: https://github.com/owner/repo.git, git@github.com:owner/repo.git, etc.
270
+ url.match(%r{(?:https://|git@)?(?:.*?)[:/](.+?)(?:\.git)?/?$})&.captures&.first
271
+ end
272
+
273
+ # Execute git command and return output
274
+ # Fails silently if git not available or not a git repo
275
+ def read_git_config(*git_args)
276
+ require 'open3'
277
+
278
+ # Check if we're in a git repository
279
+ return nil unless Dir.exist?('.git') || system('git rev-parse --git-dir > /dev/null 2>&1')
280
+
281
+ begin
282
+ stdout, status = Open3.capture2('git', *git_args)
283
+ return nil unless status.success?
284
+
285
+ output = stdout.strip
286
+ output.empty? ? nil : output
287
+ rescue Errno::ENOENT
288
+ # git command not found
289
+ nil
290
+ rescue StandardError
291
+ # Any other error (permission denied, etc.)
292
+ nil
293
+ end
294
+ end
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ # Main CLI entry point handling command routing and argument parsing
5
+ class CLI
6
+ def self.run(args)
7
+ cli = new(args)
8
+ cli.execute
9
+ end
10
+
11
+ def initialize(args)
12
+ @args = args
13
+ @command = nil
14
+ @options = {}
15
+ @path = '.'
16
+ end
17
+
18
+ def execute
19
+ parse_arguments
20
+
21
+ case @command
22
+ when 'analyze'
23
+ execute_analyze
24
+ when 'check'
25
+ execute_check
26
+ when 'help', '--help', '-h', nil
27
+ print_help
28
+ exit 0
29
+ when 'version', '--version', '-v'
30
+ print_version
31
+ exit 0
32
+ else
33
+ puts "Unknown command: #{@command}"
34
+ print_help
35
+ exit 1
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # Parse command line arguments
42
+ def parse_arguments
43
+ @command = @args.shift&.downcase
44
+
45
+ # Parse remaining arguments (options and path)
46
+ while @args.any?
47
+ arg = @args.shift
48
+
49
+ case arg
50
+ when '--help', '-h'
51
+ @options[:help] = true
52
+ when '--verbose', '-v'
53
+ @options[:verbose] = true
54
+ when '--json'
55
+ @options[:json] = true
56
+ @options[:format] = 'json'
57
+ when '--format'
58
+ format_value = @args.shift
59
+ @options[:format] = format_value
60
+ @options[:json] = true if format_value == 'json'
61
+ when '--threshold'
62
+ @options[:threshold] = @args.shift
63
+ when '--config'
64
+ @options[:config] = @args.shift
65
+ when /^-/
66
+ puts "Unknown option: #{arg}"
67
+ exit 1
68
+ else
69
+ # Assume it's a path
70
+ @path = arg
71
+ end
72
+ end
73
+ end
74
+
75
+ def execute_analyze
76
+ if @options[:help]
77
+ print_command_help('analyze')
78
+ exit 0
79
+ end
80
+
81
+ command = Commands::Analyze.new(@path, @options)
82
+ command.execute
83
+ end
84
+
85
+ def execute_check
86
+ if @options[:help]
87
+ print_command_help('check')
88
+ exit 0
89
+ end
90
+
91
+ command = Commands::Check.new(@path, @options)
92
+ exit_code = command.execute
93
+
94
+ # Exit with appropriate code for check command
95
+ exit exit_code
96
+ end
97
+
98
+ def print_help
99
+ puts <<~HELP
100
+ QueryGuard CLI v#{QueryGuard::VERSION}
101
+
102
+ A developer-friendly tool for analyzing query risk and migration safety.
103
+
104
+ USAGE:
105
+ queryguard COMMAND [OPTIONS] [PATH]
106
+
107
+ COMMANDS:
108
+ analyze Analyze a file or project for query and migration risks
109
+ check Check if risks are below configured threshold (for CI/CD)
110
+ help Show this help message
111
+ version Show version information
112
+
113
+ OPTIONS:
114
+ --help, -h Show help for command
115
+ --verbose, -v Show detailed output
116
+ --json Output results as JSON
117
+ --threshold LEVEL Set severity threshold (info, warn, error, critical)
118
+ --config FILE Load configuration from file
119
+
120
+ EXAMPLES:
121
+ queryguard analyze # Analyze current directory
122
+ queryguard analyze app/models # Analyze specific directory
123
+ queryguard check db/migrate # Check migrations against threshold
124
+ queryguard check --threshold error # Check with custom threshold
125
+
126
+ For more information, visit: https://github.com/your-org/query_guard
127
+ HELP
128
+ end
129
+
130
+ def print_command_help(command)
131
+ case command
132
+ when 'analyze'
133
+ puts <<~HELP
134
+ QueryGuard Analyze
135
+
136
+ Analyzes files for query and migration risks, printing a report.
137
+
138
+ USAGE:
139
+ queryguard analyze [OPTIONS] [PATH]
140
+
141
+ OPTIONS:
142
+ --help, -h Show this help message
143
+ --verbose, -v Show detailed information about each finding
144
+ --json Output results as JSON
145
+
146
+ EXIT CODES:
147
+ 0 No errors (risks found may still exist)
148
+ 1 Analysis failed or invalid arguments
149
+
150
+ EXAMPLES:
151
+ queryguard analyze
152
+ queryguard analyze --verbose
153
+ queryguard analyze db/migrate
154
+ queryguard analyze --json app/ > results.json
155
+ HELP
156
+ when 'check'
157
+ puts <<~HELP
158
+ QueryGuard Check
159
+
160
+ Checks if migration/query risks exceed configured threshold.
161
+ Useful for CI/CD pipelines.
162
+
163
+ USAGE:
164
+ queryguard check [OPTIONS] [PATH]
165
+
166
+ OPTIONS:
167
+ --help, -h Show this help message
168
+ --threshold LEVEL Set severity threshold (default: warn)
169
+ Levels: info, warn, error, critical
170
+ --verbose, -v Show detailed output
171
+ --json Output results as JSON
172
+
173
+ EXIT CODES:
174
+ 0 No findings above threshold (clear to deploy)
175
+ 1 Findings exceed threshold (block deployment)
176
+ 2 Check failed or invalid arguments
177
+
178
+ EXAMPLES:
179
+ queryguard check db/migrate # Check with default threshold
180
+ queryguard check --threshold error # Only fail on :error or :critical
181
+ queryguard check --threshold critical # Only fail on :critical
182
+ queryguard check db/migrate --verbose # Show all findings details
183
+ HELP
184
+ end
185
+ end
186
+
187
+ def print_version
188
+ puts "QueryGuard v#{QueryGuard::VERSION}"
189
+ end
190
+ end
191
+ end
192
+
193
+ # Require command modules
194
+ require 'query_guard/cli/formatter'
195
+ require 'query_guard/cli/command'
196
+ require 'query_guard/cli/commands/analyze'
197
+ require 'query_guard/cli/commands/check'
@@ -8,17 +8,17 @@ module QueryGuard
8
8
  DEFAULT_TIMEOUT = 5 # seconds
9
9
 
10
10
  def initialize(base_url:, api_key:, project:, env:)
11
- @base_url = base_url.sub(%r{/\z}, "") rescue ''
11
+ @base_url = base_url&.sub(%r{/\z}, "") || ""
12
12
  @api_key = api_key
13
13
  @project = project
14
14
  @env = env
15
15
  end
16
16
 
17
- # Example call used by your Subscriber/Middleware
17
+ # Bulk events POST
18
18
  def post(path, payload)
19
19
  uri = URI.parse("#{@base_url}#{path}")
20
20
  req = Net::HTTP::Post.new(uri)
21
- req["Content-Type"] = "application/json"
21
+ req["Content-Type"] = "application/json"
22
22
  req["Authorization"] = "Bearer #{@api_key}" if @api_key
23
23
  req.body = JSON.generate(payload.merge(project: @project, env: @env))
24
24
 
@@ -28,9 +28,7 @@ module QueryGuard
28
28
  http.read_timeout = DEFAULT_TIMEOUT
29
29
 
30
30
  res = http.request(req)
31
- unless res.is_a?(Net::HTTPSuccess)
32
- warn "[QueryGuard] POST #{uri} -> #{res.code} #{res.body}"
33
- end
31
+ warn "[QueryGuard] POST #{uri} -> #{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess)
34
32
  res
35
33
  rescue => e
36
34
  warn "[QueryGuard] HTTP error: #{e.class}: #{e.message}"
@@ -1,23 +1,162 @@
1
1
  # frozen_string_literal: true
2
+ require "query_guard/analyzers/base"
3
+ require "query_guard/analyzers/registry"
4
+ require "query_guard/analyzers/slow_query_analyzer"
5
+ require "query_guard/analyzers/query_count_analyzer"
6
+ require "query_guard/analyzers/select_star_analyzer"
7
+ require "query_guard/analyzers/query_risk_analyzer"
8
+ require_relative "budget"
9
+
2
10
  module QueryGuard
3
11
  class Config
4
- attr_accessor :enabled_environments, :max_queries_per_request,
5
- :max_duration_ms_per_query, :block_select_star,
6
- :ignored_sql, :raise_on_violation, :log_prefix,
7
- :base_url, :api_key, :project, :env
12
+ # User-facing configuration
13
+ attr_accessor :migrations_directory, :enabled_environments
14
+
15
+ # Analyzer control
16
+ attr_accessor :disabled_analyzers
17
+
18
+ # Severity levels for analyzers
19
+ attr_accessor :slow_query_severity, :query_count_severity,
20
+ :select_star_severity, :migration_risk_severity
21
+
22
+ # Query monitoring (optional, defaults disabled)
23
+ attr_accessor :max_queries_per_request, :max_duration_ms_per_query,
24
+ :block_select_star, :raise_on_violation,
25
+ :analyze_query_risks, # Enable/disable query risk analysis
26
+ :use_explain_plans, # Use EXPLAIN plans for query analysis
27
+ :explain_enricher, # Custom EXPLAIN enricher
28
+ :ignored_sql # SQL patterns to ignore in analysis
29
+
30
+ # Uploader configuration (SaaS ingestion, future feature)
31
+ attr_accessor :uploader_type, :api_base_url, :project_key, :api_token
32
+
33
+ attr_reader :analyzer_registry
34
+
35
+ # --- Security features ---
36
+ attr_accessor :enable_security
37
+ attr_accessor :detect_sql_injection
38
+ attr_accessor :sql_injection_patterns
39
+
40
+ attr_accessor :detect_unusual_query_pattern
41
+ attr_accessor :max_queries_per_minute_per_actor
42
+ attr_accessor :max_unique_query_fingerprints_per_minute_per_actor
43
+
44
+ attr_accessor :detect_data_exfiltration
45
+ attr_accessor :max_response_bytes_per_request
46
+ attr_accessor :exfiltration_path_regex
47
+
48
+ attr_accessor :detect_mass_assignment
49
+ attr_accessor :sensitive_param_keys
50
+
51
+ # Actor resolver for rate limiting (ip/user/token)
52
+ attr_accessor :actor_resolver
53
+
54
+ # Storage for rolling counters (defaults to in-memory)
55
+ attr_accessor :store
56
+
57
+ # Budget system
58
+ attr_reader :budget
8
59
 
9
60
  def initialize
61
+ # User-facing configuration - simplified for new users
62
+ @migrations_directory = "db/migrate"
10
63
  @enabled_environments = %i[development test]
64
+
65
+ # Query monitoring thresholds
11
66
  @max_queries_per_request = 100
12
- @max_duration_ms_per_query = 100.0 # ms; set to nil to disable
67
+ @max_duration_ms_per_query = 100.0
13
68
  @block_select_star = false
14
- @ignored_sql = [/^PRAGMA /i, /^BEGIN/i, /^COMMIT/i]
15
69
  @raise_on_violation = false
70
+ @ignored_sql = [/^PRAGMA /i, /^BEGIN/i, /^COMMIT/i]
16
71
  @log_prefix = "[QueryGuard]"
72
+
73
+ # Analyzer registry and control
74
+ @disabled_analyzers = []
75
+ @analyzer_registry = Analyzers::Registry.new
76
+ @analyze_query_risks = true
77
+ @use_explain_plans = false
78
+ @explain_enricher = nil
79
+
80
+ # Analyzer severity levels
81
+ @slow_query_severity = :warn
82
+ @query_count_severity = :warn
83
+ @select_star_severity = :warn
84
+ @migration_risk_severity = :error
85
+
86
+ # --- Security defaults (safe, low noise) ---
87
+ @enable_security = true
88
+ @detect_sql_injection = true
89
+ @sql_injection_patterns = [
90
+ /(\bor\b|\band\b)\s+\d+\s*=\s*\d+/i, # OR 1=1
91
+ /\bunion\s+select\b/i,
92
+ /--|\/\*|\*\//, # comment tokens
93
+ /;\s*(drop|alter|truncate)\b/i,
94
+ /\b(pg_sleep|sleep)\s*\(/i,
95
+ /\binformation_schema\b/i
96
+ ]
97
+
98
+ @detect_unusual_query_pattern = true
99
+ @max_queries_per_minute_per_actor = 300
100
+ @max_unique_query_fingerprints_per_minute_per_actor = 80
101
+
102
+ @detect_data_exfiltration = true
103
+ @max_response_bytes_per_request = 2_000_000 # ~2MB
104
+ @exfiltration_path_regex = %r{/(export|download|reports|dump)\b}i
105
+
106
+ @detect_mass_assignment = true
107
+ @sensitive_param_keys = %w[
108
+ admin is_admin role roles permissions permission account_id user_id
109
+ plan_id price amount balance credit debit status state
110
+ ]
111
+
112
+ @actor_resolver = lambda do |env|
113
+ env["query_guard.actor"] ||
114
+ env["action_dispatch.remote_ip"]&.to_s ||
115
+ env["REMOTE_ADDR"]&.to_s ||
116
+ "unknown"
117
+ end
118
+
119
+ @store = nil # will default to QueryGuard::Store.new
120
+
121
+ @export_mode = :async
122
+ @export_queries = :all
123
+ @max_query_events_per_req = 200
124
+ @origin_app = nil
125
+
126
+ # Budget system
127
+ @budget = Budget.new
128
+
129
+ # Uploader configuration (SaaS ingestion, future feature)
130
+ @uploader_type = 'no-op'
131
+ @api_base_url = nil
132
+ @project_key = nil
133
+ @api_token = nil
134
+
135
+ # Register default analyzers
136
+ @analyzer_registry.register(:slow_query, Analyzers::SlowQueryAnalyzer.new)
137
+ @analyzer_registry.register(:query_count, Analyzers::QueryCountAnalyzer.new)
138
+ @analyzer_registry.register(:select_star, Analyzers::SelectStarAnalyzer.new)
139
+ @analyzer_registry.register(:query_risk, Analyzers::QueryRiskAnalyzer.new)
17
140
  end
18
141
 
19
142
  def enabled?(env)
20
143
  @enabled_environments.map(&:to_sym).include?(env.to_sym)
21
144
  end
145
+
146
+ # Disable a specific analyzer by name
147
+ def disable_analyzer(name)
148
+ analyzer_sym = name.to_sym
149
+ @disabled_analyzers << analyzer_sym unless @disabled_analyzers.include?(analyzer_sym)
150
+ end
151
+
152
+ # Enable a previously disabled analyzer
153
+ def enable_analyzer(name)
154
+ @disabled_analyzers.delete(name.to_sym)
155
+ end
156
+
157
+ # Register a custom analyzer
158
+ def register_analyzer(name, analyzer)
159
+ @analyzer_registry.register(name, analyzer)
160
+ end
22
161
  end
23
162
  end