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,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'
|
data/lib/query_guard/client.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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"]
|
|
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}"
|
data/lib/query_guard/config.rb
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|