pg_reports 0.5.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7f6e8be280967b6768f1646c2cdb0fa75b1d4da7d559151faa5f6f73dd4dfdb
4
- data.tar.gz: 2121ea4314ec252406728fb6c5e98314b9703921b22d40a0b8483754dafd483c
3
+ metadata.gz: 05027e6e3278a707d98301a3d4a44dbce9930d26e963359d1afb01b57626df0c
4
+ data.tar.gz: 0c03b76ef459a04fa4ea732e062fd73020d814c5f11bcc6e3f0a771d17e66976
5
5
  SHA512:
6
- metadata.gz: a3a5d23ca386675cc409a7f8536fcd2600d0a04f1576edd15ae0a939aa78e01226e25e776bbb0ed2c66dccf4b24121329c4263389f7b17a6ccc4cf7897063433
7
- data.tar.gz: 411a3c78f3b45f3fa6c1a7a5bfee766cf0dd59bcab0bacf5e9b338971350b15d169413b1a6a99b540ecb850890bd2dafd60560e7d06e9990a023955310eba965
6
+ metadata.gz: a5e8a8eae451b10265dcbd3a9839f21d7c647d0876e7a41b7ac8e2a2219b87bbfbd45ec621f6ac552aba1c1cb8284005a114ca8758c3bd61e9cf3440ca6838d6
7
+ data.tar.gz: cf37e2c3458b8f24ca6bc548f7d58dea29af6547c14bf1c6db1468962fa1ec6e2095a9df8923045e5b8e86c6cf8c00e054e90e21cd803c7137d10db96e3f12de
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] - 2026-02-09
11
+
12
+ ### Added
13
+
14
+ - **Query Execution Security** - new configuration to control raw SQL execution from dashboard:
15
+ - New config option `allow_raw_query_execution` (default: `false`)
16
+ - Environment variable support: `PG_REPORTS_ALLOW_RAW_QUERY_EXECUTION`
17
+ - Security documentation in README with examples and best practices
18
+ - Frontend UI: disabled buttons with tooltips when feature is off
19
+ - JavaScript validation in `executeQuery()` and `executeExplainAnalyze()` functions
20
+ - Configuration tests for new security setting
21
+
22
+ ### Changed
23
+
24
+ - **BREAKING CHANGE**: `execute_query` and `explain_analyze` endpoints now require explicit opt-in
25
+ - Both endpoints return 403 Forbidden when `allow_raw_query_execution` is `false` (default)
26
+ - Dashboard "Execute Query" and "EXPLAIN ANALYZE" buttons disabled by default
27
+ - To restore previous behavior, add to initializer: `config.allow_raw_query_execution = true`
28
+ - **Migration path**: Users must explicitly enable this feature if they were using Query Analyzer
29
+
30
+ ### Security
31
+
32
+ - Raw SQL execution from dashboard is now **disabled by default** to prevent unauthorized data access
33
+ - Recommended setup: only enable in development/staging environments
34
+ - Existing safety measures (SELECT/SHOW only, automatic LIMIT) still apply when enabled
35
+
10
36
  ## [0.5.0] - 2026-02-07
11
37
 
12
38
  ### Added
data/README.md CHANGED
@@ -128,6 +128,47 @@ PgReports.configure do |config|
128
128
  end
129
129
  ```
130
130
 
131
+ ### Query Execution Security
132
+
133
+ ⚠️ **Security Warning**: By default, the dashboard **does not allow** executing raw SQL queries via "Execute Query" and "EXPLAIN ANALYZE" buttons. This prevents accidental or malicious query execution in production environments.
134
+
135
+ To enable query execution (only in secure environments):
136
+
137
+ ```ruby
138
+ PgReports.configure do |config|
139
+ # Enable query execution from dashboard (default: false)
140
+ config.allow_raw_query_execution = true
141
+ end
142
+ ```
143
+
144
+ Or via environment variable:
145
+
146
+ ```bash
147
+ export PG_REPORTS_ALLOW_RAW_QUERY_EXECUTION=true
148
+ ```
149
+
150
+ **Recommended setup** (only enable in development/staging):
151
+
152
+ ```ruby
153
+ # config/initializers/pg_reports.rb
154
+ PgReports.configure do |config|
155
+ # Only allow query execution in development/staging
156
+ config.allow_raw_query_execution = Rails.env.development? || Rails.env.staging?
157
+
158
+ # Combine with authentication for additional security
159
+ config.dashboard_auth = -> {
160
+ authenticate_or_request_with_http_basic do |user, pass|
161
+ user == ENV["PG_REPORTS_USER"] && pass == ENV["PG_REPORTS_PASSWORD"]
162
+ end
163
+ }
164
+ end
165
+ ```
166
+
167
+ When disabled:
168
+ - API endpoints `/execute_query` and `/explain_analyze` return 403 Forbidden
169
+ - UI buttons are disabled with explanation tooltips
170
+ - Existing safety measures (SELECT/SHOW only, automatic LIMIT) still apply when enabled
171
+
131
172
  ## Query Source Tracking
132
173
 
133
174
  PgReports automatically parses query annotations to show **where queries originated**. Works with:
@@ -26,15 +26,40 @@ module PgReports
26
26
 
27
27
  def live_metrics
28
28
  threshold = params[:long_query_threshold]&.to_i || 60
29
- data = Modules::System.live_metrics(long_query_threshold: threshold)
30
29
 
31
- render json: {
32
- success: true,
33
- metrics: data,
34
- timestamp: Time.current.to_i
35
- }
36
- rescue => e
37
- render json: {success: false, error: e.message}, status: :unprocessable_entity
30
+ # Check if we have access to required statistics
31
+ begin
32
+ data = Modules::System.live_metrics(long_query_threshold: threshold)
33
+
34
+ # Validate that we got actual data
35
+ if data[:connections][:total].nil? && data[:transactions][:total].nil?
36
+ render json: {
37
+ success: false,
38
+ error: "Unable to fetch database statistics. Check database permissions.",
39
+ available: false
40
+ }, status: :service_unavailable
41
+ return
42
+ end
43
+
44
+ render json: {
45
+ success: true,
46
+ metrics: data,
47
+ timestamp: Time.current.to_i,
48
+ available: true
49
+ }
50
+ rescue PG::InsufficientPrivilege => e
51
+ render json: {
52
+ success: false,
53
+ error: "Insufficient database permissions to access statistics views",
54
+ available: false
55
+ }, status: :forbidden
56
+ rescue => e
57
+ render json: {
58
+ success: false,
59
+ error: e.message,
60
+ available: false
61
+ }, status: :unprocessable_entity
62
+ end
38
63
  end
39
64
 
40
65
  def show
@@ -73,11 +98,24 @@ module PgReports
73
98
  problem_fields = Dashboard::ReportsRegistry.problem_fields(report_key)
74
99
  problem_explanations = load_problem_explanations(category, report_key)
75
100
 
101
+ # Add query hashes for security
102
+ data_with_hashes = report.data.first(100).map do |row|
103
+ row_hash = row.dup
104
+
105
+ # If this row contains a query column, store it with a hash
106
+ if row_hash.key?("query") && row_hash["query"].present?
107
+ query_hash = store_query_with_hash(row_hash["query"])
108
+ row_hash["query_hash"] = query_hash
109
+ end
110
+
111
+ row_hash
112
+ end
113
+
76
114
  render json: {
77
115
  success: true,
78
116
  title: report.title,
79
117
  columns: report.columns,
80
- data: report.data.first(100),
118
+ data: data_with_hashes,
81
119
  total: report.size,
82
120
  generated_at: report.generated_at.strftime("%Y-%m-%d %H:%M:%S"),
83
121
  thresholds: thresholds,
@@ -135,18 +173,42 @@ module PgReports
135
173
  end
136
174
 
137
175
  def explain_analyze
138
- query = params[:query]
176
+ query_hash = params[:query_hash]
139
177
  query_params = params[:params] || {}
140
178
 
141
- if query.blank?
142
- render json: {success: false, error: "Query is required"}, status: :unprocessable_entity
179
+ if query_hash.blank?
180
+ render json: {success: false, error: "Query hash is required"}, status: :unprocessable_entity
143
181
  return
144
182
  end
145
183
 
146
- # Security: Only allow SELECT queries for EXPLAIN ANALYZE (SHOW not supported by EXPLAIN)
147
- normalized = query.strip.gsub(/\s+/, " ").downcase
148
- unless normalized.start_with?("select")
149
- render json: {success: false, error: "Only SELECT queries are allowed for EXPLAIN ANALYZE"}, status: :unprocessable_entity
184
+ # Security: Check if raw query execution is allowed
185
+ unless PgReports.config.allow_raw_query_execution
186
+ render json: {
187
+ success: false,
188
+ error: "Query execution from dashboard is disabled. Enable it in configuration with 'config.allow_raw_query_execution = true'"
189
+ }, status: :forbidden
190
+ return
191
+ end
192
+
193
+ # Security: Retrieve and validate query by hash
194
+ begin
195
+ query = retrieve_query_by_hash(query_hash)
196
+
197
+ if query.nil?
198
+ render json: {success: false, error: "Query not found or expired. Please refresh the page."}, status: :not_found
199
+ return
200
+ end
201
+ rescue SecurityError => e
202
+ render json: {success: false, error: "Security violation: #{e.message}"}, status: :forbidden
203
+ return
204
+ end
205
+
206
+ # Check for trigger variables (NEW, OLD) which are only available in trigger context
207
+ if query.match?(/\b(NEW|OLD)\./i)
208
+ render json: {
209
+ success: false,
210
+ error: "Cannot EXPLAIN ANALYZE queries with trigger variables (NEW, OLD). These are only available within trigger functions."
211
+ }, status: :unprocessable_entity
150
212
  return
151
213
  end
152
214
 
@@ -182,18 +244,33 @@ module PgReports
182
244
  end
183
245
 
184
246
  def execute_query
185
- query = params[:query]
247
+ query_hash = params[:query_hash]
186
248
  query_params = params[:params] || {}
187
249
 
188
- if query.blank?
189
- render json: {success: false, error: "Query is required"}, status: :unprocessable_entity
250
+ if query_hash.blank?
251
+ render json: {success: false, error: "Query hash is required"}, status: :unprocessable_entity
190
252
  return
191
253
  end
192
254
 
193
- # Security: Only allow SELECT and SHOW queries
194
- normalized = query.strip.gsub(/\s+/, " ").downcase
195
- unless normalized.start_with?("select", "show")
196
- render json: {success: false, error: "Only SELECT and SHOW queries are allowed"}, status: :unprocessable_entity
255
+ # Security: Check if raw query execution is allowed
256
+ unless PgReports.config.allow_raw_query_execution
257
+ render json: {
258
+ success: false,
259
+ error: "Query execution from dashboard is disabled. Enable it in configuration with 'config.allow_raw_query_execution = true'"
260
+ }, status: :forbidden
261
+ return
262
+ end
263
+
264
+ # Security: Retrieve and validate query by hash
265
+ begin
266
+ query = retrieve_query_by_hash(query_hash)
267
+
268
+ if query.nil?
269
+ render json: {success: false, error: "Query not found or expired. Please refresh the page."}, status: :not_found
270
+ return
271
+ end
272
+ rescue SecurityError => e
273
+ render json: {success: false, error: "Security violation: #{e.message}"}, status: :forbidden
197
274
  return
198
275
  end
199
276
 
@@ -285,7 +362,7 @@ module PgReports
285
362
  end
286
363
 
287
364
  def start_query_monitoring
288
- monitor = QueryMonitor.instance
365
+ monitor = PgReports::QueryMonitor.instance
289
366
 
290
367
  result = monitor.start
291
368
 
@@ -299,7 +376,7 @@ module PgReports
299
376
  end
300
377
 
301
378
  def stop_query_monitoring
302
- monitor = QueryMonitor.instance
379
+ monitor = PgReports::QueryMonitor.instance
303
380
 
304
381
  result = monitor.stop
305
382
 
@@ -313,7 +390,7 @@ module PgReports
313
390
  end
314
391
 
315
392
  def query_monitor_status
316
- monitor = QueryMonitor.instance
393
+ monitor = PgReports::QueryMonitor.instance
317
394
  status = monitor.status
318
395
 
319
396
  render json: {
@@ -327,7 +404,7 @@ module PgReports
327
404
  end
328
405
 
329
406
  def query_monitor_feed
330
- monitor = QueryMonitor.instance
407
+ monitor = PgReports::QueryMonitor.instance
331
408
 
332
409
  unless monitor.enabled
333
410
  render json: {success: false, message: "Monitoring not active"}
@@ -349,9 +426,9 @@ module PgReports
349
426
  end
350
427
 
351
428
  def load_query_history
352
- monitor = QueryMonitor.instance
429
+ monitor = PgReports::QueryMonitor.instance
353
430
 
354
- limit = params[:limit]&.to_i || 100
431
+ limit = params[:limit]&.to_i || 50
355
432
  session_id = params[:session_id]
356
433
 
357
434
  queries = monitor.load_from_log(limit: limit, session_id: session_id)
@@ -366,7 +443,7 @@ module PgReports
366
443
  end
367
444
 
368
445
  def download_query_monitor
369
- monitor = QueryMonitor.instance
446
+ monitor = PgReports::QueryMonitor.instance
370
447
 
371
448
  # Allow download even when monitoring is stopped, as long as there are queries
372
449
  queries = monitor.queries
@@ -510,6 +587,67 @@ module PgReports
510
587
  end
511
588
  end
512
589
 
590
+ # Generate a secure hash for a query and store it in cache
591
+ def store_query_with_hash(query)
592
+ require "digest"
593
+
594
+ # Generate SHA256 hash of the query
595
+ query_hash = Digest::SHA256.hexdigest(query)
596
+
597
+ # Store in Rails cache with 1 hour expiration
598
+ begin
599
+ Rails.cache.write("pg_reports:query:#{query_hash}", query, expires_in: 1.hour)
600
+ rescue => e
601
+ # Log cache error but don't fail - we'll catch it on retrieval
602
+ Rails.logger.warn("PgReports: Failed to store query hash in cache: #{e.message}") if defined?(Rails.logger)
603
+ end
604
+
605
+ query_hash
606
+ end
607
+
608
+ # Retrieve and validate a query by its hash
609
+ def retrieve_query_by_hash(query_hash)
610
+ return nil if query_hash.blank?
611
+
612
+ # Retrieve from cache with error handling
613
+ query = nil
614
+ begin
615
+ query = Rails.cache.read("pg_reports:query:#{query_hash}")
616
+ rescue => e
617
+ # Cache backend is unavailable
618
+ Rails.logger.error("PgReports: Failed to read from cache: #{e.message}") if defined?(Rails.logger)
619
+ raise SecurityError, "Cache system unavailable. Query execution temporarily disabled for security. Please ensure Redis/cache backend is running."
620
+ end
621
+
622
+ if query.blank?
623
+ # Query not found - either expired or was never stored
624
+ return nil
625
+ end
626
+
627
+ # Strict validation: must be a SELECT query only
628
+ normalized = query.strip.gsub(/\s+/, " ").downcase
629
+
630
+ # Check for semicolons (prevents multiple statements)
631
+ if query.include?(";")
632
+ raise SecurityError, "Multiple statements are not allowed (semicolons detected)"
633
+ end
634
+
635
+ # Must start with SELECT (case insensitive)
636
+ unless normalized.start_with?("select")
637
+ raise SecurityError, "Only SELECT queries are allowed. Found: #{normalized.split.first&.upcase || 'unknown'}"
638
+ end
639
+
640
+ # Check for dangerous keywords that might be in subqueries or CTEs
641
+ dangerous_keywords = %w[insert update delete drop alter create truncate grant revoke]
642
+ dangerous_keywords.each do |keyword|
643
+ if normalized.match?(/\b#{keyword}\b/)
644
+ raise SecurityError, "Dangerous keyword detected: #{keyword.upcase}"
645
+ end
646
+ end
647
+
648
+ query
649
+ end
650
+
513
651
  def generate_query_monitor_csv(queries)
514
652
  require "csv"
515
653