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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +41 -0
- data/app/controllers/pg_reports/dashboard_controller.rb +168 -30
- data/app/views/layouts/pg_reports/application.html.erb +133 -124
- data/app/views/pg_reports/dashboard/_show_modals.html.erb +1 -1
- data/app/views/pg_reports/dashboard/_show_scripts.html.erb +70 -6
- data/app/views/pg_reports/dashboard/index.html.erb +91 -30
- data/lib/pg_reports/configuration.rb +8 -0
- data/lib/pg_reports/modules/system.rb +7 -1
- data/lib/pg_reports/query_monitor.rb +23 -10
- data/lib/pg_reports/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 05027e6e3278a707d98301a3d4a44dbce9930d26e963359d1afb01b57626df0c
|
|
4
|
+
data.tar.gz: 0c03b76ef459a04fa4ea732e062fd73020d814c5f11bcc6e3f0a771d17e66976
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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:
|
|
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
|
-
|
|
176
|
+
query_hash = params[:query_hash]
|
|
139
177
|
query_params = params[:params] || {}
|
|
140
178
|
|
|
141
|
-
if
|
|
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:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
247
|
+
query_hash = params[:query_hash]
|
|
186
248
|
query_params = params[:params] || {}
|
|
187
249
|
|
|
188
|
-
if
|
|
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:
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 ||
|
|
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
|
|