query_guard 0.4.1 → 0.5.0
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 +287 -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,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Suggest
|
|
5
|
+
# Generates index suggestions for queries with missing index indicators.
|
|
6
|
+
# Conservative approach: only suggests when patterns match common cases.
|
|
7
|
+
# All suggestions include disclaimer that they're recommendations only.
|
|
8
|
+
#
|
|
9
|
+
# Example:
|
|
10
|
+
# suggester = IndexSuggester.new
|
|
11
|
+
# suggestion = suggester.suggest_for_sequential_scan(
|
|
12
|
+
# "SELECT * FROM users WHERE email = 'test@example.com'",
|
|
13
|
+
# table_name: "users"
|
|
14
|
+
# )
|
|
15
|
+
# # => {
|
|
16
|
+
# # suggested_index_sql: "CREATE INDEX idx_users_email ON users (email);",
|
|
17
|
+
# # explanation: "Index on email column for WHERE clause equality",
|
|
18
|
+
# # confidence: :medium,
|
|
19
|
+
# # columns: ["email"]
|
|
20
|
+
# # }
|
|
21
|
+
class IndexSuggester
|
|
22
|
+
CONFIDENCE_LEVELS = %i[high medium low].freeze
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@extractors = PatternExtractors.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Generate index suggestion for sequential scan with filter
|
|
29
|
+
#
|
|
30
|
+
# @param sql [String] SQL query
|
|
31
|
+
# @param table_name [String] Table being scanned
|
|
32
|
+
# @param filter_condition [String] WHERE clause filter (optional)
|
|
33
|
+
# @return [Hash, nil] Suggestion hash or nil if no reasonable suggestion
|
|
34
|
+
def suggest_for_sequential_scan(sql, table_name:, filter_condition: nil)
|
|
35
|
+
return nil if sql.nil? || table_name.nil?
|
|
36
|
+
|
|
37
|
+
# Extract columns from WHERE clause
|
|
38
|
+
where_cols = @extractors.extract_where_columns(sql)
|
|
39
|
+
return nil if where_cols.empty?
|
|
40
|
+
|
|
41
|
+
# Use first column as primary index candidate
|
|
42
|
+
# This is conservative: most benefit comes from first filter
|
|
43
|
+
primary_col = where_cols.first
|
|
44
|
+
suggest_index(table_name, primary_col, high_confidence: true)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Generate index suggestion for expensive sort
|
|
48
|
+
#
|
|
49
|
+
# @param sql [String] SQL query
|
|
50
|
+
# @param table_name [String] Table being sorted
|
|
51
|
+
# @return [Hash, nil] Suggestion hash or nil
|
|
52
|
+
def suggest_for_expensive_sort(sql, table_name:)
|
|
53
|
+
return nil if sql.nil? || table_name.nil?
|
|
54
|
+
|
|
55
|
+
# Extract ORDER BY columns
|
|
56
|
+
order_cols = @extractors.extract_order_by_columns(sql)
|
|
57
|
+
return nil if order_cols.empty?
|
|
58
|
+
|
|
59
|
+
# For sorts, index all ORDER BY columns in order
|
|
60
|
+
suggest_composite_index(table_name, order_cols, reason: "sort")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Generate index suggestion for multi-column filter
|
|
64
|
+
# For WHERE with multiple equality conditions, suggest composite
|
|
65
|
+
#
|
|
66
|
+
# @param sql [String] SQL query
|
|
67
|
+
# @param table_name [String] Table being filtered
|
|
68
|
+
# @return [Hash, nil] Suggestion hash or nil
|
|
69
|
+
def suggest_for_complex_filter(sql, table_name:)
|
|
70
|
+
return nil if sql.nil? || table_name.nil?
|
|
71
|
+
|
|
72
|
+
where_cols = @extractors.extract_where_columns(sql)
|
|
73
|
+
return nil if where_cols.length < 2
|
|
74
|
+
|
|
75
|
+
# Conservative: only suggest composite for 2-3 columns
|
|
76
|
+
return nil if where_cols.length > 3
|
|
77
|
+
|
|
78
|
+
suggest_composite_index(table_name, where_cols, reason: "composite filter")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Generate index suggestion for JOIN condition
|
|
82
|
+
# Suggests index on join key in inner table
|
|
83
|
+
#
|
|
84
|
+
# @param column_name [String] Join column name
|
|
85
|
+
# @param table_name [String] Inner table name
|
|
86
|
+
# @return [Hash] Suggestion hash
|
|
87
|
+
def suggest_for_join(column_name, table_name:)
|
|
88
|
+
return nil if column_name.nil? || table_name.nil?
|
|
89
|
+
|
|
90
|
+
suggest_index(table_name, column_name, high_confidence: true, reason: "join condition")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Build neutral recommendation text for a suggestion
|
|
94
|
+
#
|
|
95
|
+
# @param suggestion [Hash] Suggestion from suggest_* methods
|
|
96
|
+
# @return [String] User-facing recommendation text
|
|
97
|
+
def build_recommendation_text(suggestion)
|
|
98
|
+
return nil unless suggestion
|
|
99
|
+
|
|
100
|
+
text = suggestion[:explanation]
|
|
101
|
+
text += " (confidence: #{suggestion[:confidence]})"
|
|
102
|
+
text += "\n\nIMPORTANT: This is a recommendation based on query pattern analysis. "
|
|
103
|
+
text += "Before creating the index:\n"
|
|
104
|
+
text += " 1. Verify this index hasn't already been suggested elsewhere\n"
|
|
105
|
+
text += " 2. Check index size impact and maintenance cost\n"
|
|
106
|
+
text += " 3. Run EXPLAIN ANALYZE with and without the index\n"
|
|
107
|
+
text += " 4. Consider selectivity of indexed columns\n"
|
|
108
|
+
text += " 5. Test in development first\n\n"
|
|
109
|
+
text += "Suggested SQL (REVIEW BEFORE RUNNING):\n#{suggestion[:suggested_index_sql]}"
|
|
110
|
+
text
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def suggest_index(table_name, column_name, high_confidence: false, reason: nil)
|
|
116
|
+
col_safe = sanitize_identifier(column_name)
|
|
117
|
+
table_safe = sanitize_identifier(table_name)
|
|
118
|
+
index_name = "idx_#{table_safe}_#{col_safe}".downcase
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
suggested_index_sql: "CREATE INDEX #{index_name} ON #{table_safe} (#{col_safe});",
|
|
122
|
+
explanation: build_explanation(reason || "column filter", [column_name]),
|
|
123
|
+
confidence: high_confidence ? :high : :medium,
|
|
124
|
+
columns: [column_name],
|
|
125
|
+
index_name: index_name,
|
|
126
|
+
table_name: table_name
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def suggest_composite_index(table_name, column_names, reason: nil)
|
|
131
|
+
return nil if column_names.empty?
|
|
132
|
+
|
|
133
|
+
# Conservative: don't suggest composite for too many columns
|
|
134
|
+
return nil if column_names.length > 4
|
|
135
|
+
|
|
136
|
+
table_safe = sanitize_identifier(table_name)
|
|
137
|
+
cols_safe = column_names.map { |c| sanitize_identifier(c) }
|
|
138
|
+
col_list = cols_safe.join(", ")
|
|
139
|
+
|
|
140
|
+
# Index name: idx_table_col1_col2
|
|
141
|
+
index_name = "idx_#{table_safe}_#{cols_safe.join('_')}".downcase[0...63] # 63 char limit
|
|
142
|
+
|
|
143
|
+
{
|
|
144
|
+
suggested_index_sql: "CREATE INDEX #{index_name} ON #{table_safe} (#{col_list});",
|
|
145
|
+
explanation: build_explanation(reason || "composite filter", column_names),
|
|
146
|
+
confidence: :medium,
|
|
147
|
+
columns: column_names,
|
|
148
|
+
index_name: index_name,
|
|
149
|
+
table_name: table_name
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_explanation(reason, columns)
|
|
154
|
+
col_str = columns.length == 1 ? "#{columns[0]} column" : "#{columns.join(', ')} columns"
|
|
155
|
+
case reason
|
|
156
|
+
when "sort"
|
|
157
|
+
"Index on #{col_str} for ORDER BY clause"
|
|
158
|
+
when "composite filter"
|
|
159
|
+
"Composite index on #{col_str} for WHERE clause"
|
|
160
|
+
when "join condition"
|
|
161
|
+
"Index on #{col_str} for JOIN condition"
|
|
162
|
+
else
|
|
163
|
+
"Index on #{col_str} for #{reason}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def sanitize_identifier(identifier)
|
|
168
|
+
# Basic sanitization: allow alphanumeric and underscore
|
|
169
|
+
# PostgreSQL identifiers: letters, digits, underscores
|
|
170
|
+
sanitized = identifier.to_s.downcase.gsub(/[^a-z0-9_]/, '_')
|
|
171
|
+
# Remove leading digits (invalid)
|
|
172
|
+
sanitized.gsub(/^[0-9]+/, '')
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Suggest
|
|
5
|
+
# Extracts SQL patterns from queries for index suggestion.
|
|
6
|
+
# Conservative patterns only - only suggests when reasonably confident.
|
|
7
|
+
#
|
|
8
|
+
# Example:
|
|
9
|
+
# extractor = PatternExtractors.new
|
|
10
|
+
# where_cols = extractor.extract_where_columns("SELECT * FROM users WHERE email = 'test@example.com'")
|
|
11
|
+
# # => ["email"]
|
|
12
|
+
#
|
|
13
|
+
# order_cols = extractor.extract_order_by_columns("SELECT * FROM events ORDER BY created_at DESC")
|
|
14
|
+
# # => ["created_at"]
|
|
15
|
+
class PatternExtractors
|
|
16
|
+
# Extract column names from WHERE clause
|
|
17
|
+
# Handles: simple equality, comparison operators
|
|
18
|
+
# Avoids: functions, expressions, BETWEEN
|
|
19
|
+
#
|
|
20
|
+
# @param sql [String] SQL query
|
|
21
|
+
# @return [Array<String>] Column names in WHERE clause
|
|
22
|
+
def extract_where_columns(sql)
|
|
23
|
+
return [] if sql.nil? || sql.empty?
|
|
24
|
+
|
|
25
|
+
normalized = normalize_sql(sql)
|
|
26
|
+
where_match = normalized.match(/\bWHERE\s+(.+?)(?:\s+(?:GROUP|ORDER|LIMIT|HAVING)(?:\s|$)|$)/i)
|
|
27
|
+
return [] unless where_match
|
|
28
|
+
|
|
29
|
+
where_clause = where_match[1]
|
|
30
|
+
extract_columns_from_where(where_clause)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Extract column names from ORDER BY clause
|
|
34
|
+
# Handles: simple column ordering
|
|
35
|
+
# Avoids: expressions, COLLATE, NULLS FIRST/LAST
|
|
36
|
+
#
|
|
37
|
+
# @param sql [String] SQL query
|
|
38
|
+
# @return [Array<String>] Column names in ORDER BY clause
|
|
39
|
+
def extract_order_by_columns(sql)
|
|
40
|
+
return [] if sql.nil? || sql.empty?
|
|
41
|
+
|
|
42
|
+
normalized = normalize_sql(sql)
|
|
43
|
+
order_match = normalized.match(/\bORDER\s+BY\s+(.+?)(?:\s+(?:LIMIT)(?:\s|$)|$)/i)
|
|
44
|
+
return [] unless order_match
|
|
45
|
+
|
|
46
|
+
order_clause = order_match[1]
|
|
47
|
+
extract_columns_from_order_by(order_clause)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Extract both WHERE and ORDER BY columns
|
|
51
|
+
# Returns them in a structured format for index suggestion
|
|
52
|
+
#
|
|
53
|
+
# @param sql [String] SQL query
|
|
54
|
+
# @return [Hash] { where_columns: [...], order_by_columns: [...] }
|
|
55
|
+
def extract_all_columns(sql)
|
|
56
|
+
{
|
|
57
|
+
where_columns: extract_where_columns(sql),
|
|
58
|
+
order_by_columns: extract_order_by_columns(sql)
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Extract table name from query
|
|
63
|
+
# Simple extraction of first table mentioned after FROM
|
|
64
|
+
#
|
|
65
|
+
# @param sql [String] SQL query
|
|
66
|
+
# @return [String, nil] Table name
|
|
67
|
+
def extract_table_name(sql)
|
|
68
|
+
return nil if sql.nil? || sql.empty?
|
|
69
|
+
|
|
70
|
+
normalized = normalize_sql(sql)
|
|
71
|
+
# Match FROM or JOIN followed by table name
|
|
72
|
+
# Handles: "FROM users", "FROM public.users", "JOIN orders ON"
|
|
73
|
+
match = normalized.match(/\b(?:FROM|JOIN)\s+(?:(?:\w+\.)?(\w+))\b/i)
|
|
74
|
+
match&.[](1)&.downcase
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def normalize_sql(sql)
|
|
80
|
+
# Remove comments and extra whitespace
|
|
81
|
+
sql
|
|
82
|
+
.gsub(/--.*$/, '') # Remove line comments
|
|
83
|
+
.gsub(/\/\*.*?\*\//m, '') # Remove block comments
|
|
84
|
+
.gsub(/\s+/, ' ') # Collapse whitespace
|
|
85
|
+
.strip
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def extract_columns_from_where(where_clause)
|
|
89
|
+
columns = []
|
|
90
|
+
|
|
91
|
+
# Match simple patterns: column = value, column > value, column IN (...)
|
|
92
|
+
# Skip function calls and complex expressions
|
|
93
|
+
where_clause.scan(/\b(\w+)\s*(?:=|<>|!=|>|<|>=|<=|LIKE|IN)/i) do |match|
|
|
94
|
+
col = match[0].downcase
|
|
95
|
+
# Skip obvious non-column keywords
|
|
96
|
+
next if skip_keyword?(col)
|
|
97
|
+
|
|
98
|
+
columns << col unless columns.include?(col)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
columns
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def extract_columns_from_order_by(order_clause)
|
|
105
|
+
columns = []
|
|
106
|
+
|
|
107
|
+
# Match column names before ASC/DESC/comma
|
|
108
|
+
# Pattern: word (optionally preceded by table name)
|
|
109
|
+
order_clause.split(',').each do |part|
|
|
110
|
+
# Remove ASC/DESC modifiers
|
|
111
|
+
cleaned = part.gsub(/\s+(?:ASC|DESC)\s*$/i, '').strip
|
|
112
|
+
|
|
113
|
+
# Extract column name (handle schema.table.column references)
|
|
114
|
+
col_match = cleaned.match(/\b(\w+)\s*$/)
|
|
115
|
+
next unless col_match
|
|
116
|
+
|
|
117
|
+
col = col_match[1].downcase
|
|
118
|
+
next if skip_keyword?(col)
|
|
119
|
+
|
|
120
|
+
columns << col unless columns.include?(col)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
columns
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def skip_keyword?(word)
|
|
127
|
+
# Skip SQL keywords and common aliases
|
|
128
|
+
keywords = %w[
|
|
129
|
+
and or not null true false case when then else end
|
|
130
|
+
select insert update delete from where join on group having order by limit offset
|
|
131
|
+
asc desc ignore using force straight_join key
|
|
132
|
+
]
|
|
133
|
+
keywords.include?(word.downcase)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module QueryGuard
|
|
5
|
+
# Block-based tracing API for manual query tracking.
|
|
6
|
+
# Usable in console, tests, and Rails controllers/jobs.
|
|
7
|
+
module Trace
|
|
8
|
+
class Report
|
|
9
|
+
attr_reader :label, :context, :stats, :violations
|
|
10
|
+
|
|
11
|
+
def initialize(label, context, stats, violations)
|
|
12
|
+
@label = label
|
|
13
|
+
@context = context
|
|
14
|
+
@stats = stats
|
|
15
|
+
@violations = violations
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def query_count
|
|
19
|
+
@stats[:count]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def total_duration_ms
|
|
23
|
+
@stats[:total_duration_ms]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def queries
|
|
27
|
+
@stats[:queries] || []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fingerprints
|
|
31
|
+
@stats[:fingerprints] || {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def has_violations?
|
|
35
|
+
!@violations.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_h
|
|
39
|
+
{
|
|
40
|
+
label: @label,
|
|
41
|
+
context: @context,
|
|
42
|
+
query_count: query_count,
|
|
43
|
+
total_duration_ms: total_duration_ms,
|
|
44
|
+
violations: @violations,
|
|
45
|
+
fingerprints: @fingerprints
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
# Trace a block of code and capture query stats.
|
|
53
|
+
# Returns [result, report] tuple.
|
|
54
|
+
#
|
|
55
|
+
# Example:
|
|
56
|
+
# result, report = QueryGuard.trace("load users") do
|
|
57
|
+
# User.where(active: true).limit(10).to_a
|
|
58
|
+
# end
|
|
59
|
+
#
|
|
60
|
+
# puts report.query_count
|
|
61
|
+
# puts report.total_duration_ms
|
|
62
|
+
#
|
|
63
|
+
# With context:
|
|
64
|
+
# result, report = QueryGuard.trace("process batch", context: { batch_id: 123 }) do
|
|
65
|
+
# # ... code ...
|
|
66
|
+
# end
|
|
67
|
+
def trace(label, context: {})
|
|
68
|
+
# Initialize thread-local stats
|
|
69
|
+
previous_stats = Thread.current[:query_guard_stats]
|
|
70
|
+
Thread.current[:query_guard_stats] = {
|
|
71
|
+
request_id: "trace-#{SecureRandom.hex(4)}",
|
|
72
|
+
count: 0,
|
|
73
|
+
total_duration_ms: 0.0,
|
|
74
|
+
violations: [],
|
|
75
|
+
fingerprints: Hash.new(0),
|
|
76
|
+
response_bytes: 0,
|
|
77
|
+
queries: [],
|
|
78
|
+
request: context
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Execute the block
|
|
82
|
+
result = yield
|
|
83
|
+
|
|
84
|
+
# Capture stats
|
|
85
|
+
stats = Thread.current[:query_guard_stats]
|
|
86
|
+
violations = stats[:violations].dup
|
|
87
|
+
|
|
88
|
+
# Check budget if configured
|
|
89
|
+
if QueryGuard.respond_to?(:config) && QueryGuard.config
|
|
90
|
+
budget = QueryGuard.config.budget
|
|
91
|
+
if budget
|
|
92
|
+
budget_violations = budget.check(label, stats)
|
|
93
|
+
violations.concat(budget_violations)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Build report
|
|
98
|
+
report = Report.new(label, context, stats, violations)
|
|
99
|
+
|
|
100
|
+
[result, report]
|
|
101
|
+
ensure
|
|
102
|
+
# Restore previous stats
|
|
103
|
+
Thread.current[:query_guard_stats] = previous_stats
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Uploader
|
|
5
|
+
# HTTP uploader for sending reports to a remote QueryGuard API.
|
|
6
|
+
#
|
|
7
|
+
# This is a template/stub uploader for future SaaS platform integration.
|
|
8
|
+
# It provides the interface for authenticated HTTPS uploads but is not
|
|
9
|
+
# yet connected to a production service.
|
|
10
|
+
#
|
|
11
|
+
# Configuration:
|
|
12
|
+
# config.uploader_type = 'http'
|
|
13
|
+
# config.api_base_url = 'https://api.queryguard.example.com'
|
|
14
|
+
# config.project_key = 'proj-123'
|
|
15
|
+
# config.api_token = 'token-secret'
|
|
16
|
+
#
|
|
17
|
+
# Use case (future):
|
|
18
|
+
# - Upload findings to hosted QueryGuard platform
|
|
19
|
+
# - Centralized reporting & analytics
|
|
20
|
+
# - Team collaboration on findings
|
|
21
|
+
# - Integration with compliance dashboards
|
|
22
|
+
class HttpUploader < Interface
|
|
23
|
+
# Create HTTP uploader from config.
|
|
24
|
+
#
|
|
25
|
+
# @param config [QueryGuard::Config] Configuration object with API details
|
|
26
|
+
def initialize(config)
|
|
27
|
+
@config = config
|
|
28
|
+
@api_base_url = config.api_base_url
|
|
29
|
+
@project_key = config.project_key
|
|
30
|
+
@api_token = config.api_token
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Upload JSON report to remote API.
|
|
34
|
+
#
|
|
35
|
+
# @param json_report [String] JSON report string
|
|
36
|
+
# @param metadata [Hash] Additional metadata (tracing, timing, etc.)
|
|
37
|
+
# @return [UploadResult]
|
|
38
|
+
def upload(json_report, metadata = {})
|
|
39
|
+
unless ready?
|
|
40
|
+
return UploadResult.new(
|
|
41
|
+
success: false,
|
|
42
|
+
uploader_name: name,
|
|
43
|
+
error: "HTTP uploader not properly configured (missing #{missing_fields.join(', ')})"
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Parse report to extract metadata
|
|
48
|
+
report_data = parse_report(json_report)
|
|
49
|
+
|
|
50
|
+
perform_upload(json_report, report_data, metadata)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
UploadResult.new(
|
|
53
|
+
success: false,
|
|
54
|
+
uploader_name: name,
|
|
55
|
+
error: "Upload failed: #{e.message}"
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if configuration is complete.
|
|
60
|
+
def ready?
|
|
61
|
+
!!(@api_base_url && !@api_base_url.empty? &&
|
|
62
|
+
@project_key && !@project_key.empty? &&
|
|
63
|
+
@api_token && !@api_token.empty?)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def name
|
|
67
|
+
'http'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def status
|
|
71
|
+
{
|
|
72
|
+
enabled: ready?,
|
|
73
|
+
mode: 'http',
|
|
74
|
+
api_url: @api_base_url,
|
|
75
|
+
project_key: @project_key,
|
|
76
|
+
description: ready? ? 'Configured for remote API upload' : 'Not configured (missing credentials)'
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Missing required fields for upload.
|
|
83
|
+
def missing_fields
|
|
84
|
+
fields = []
|
|
85
|
+
fields << 'api_base_url' unless @api_base_url && !@api_base_url.empty?
|
|
86
|
+
fields << 'project_key' unless @project_key && !@project_key.empty?
|
|
87
|
+
fields << 'api_token' unless @api_token && !@api_token.empty?
|
|
88
|
+
fields
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Extract basic info from JSON report.
|
|
92
|
+
def parse_report(json_str)
|
|
93
|
+
JSON.parse(json_str)
|
|
94
|
+
rescue StandardError
|
|
95
|
+
{}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Perform the actual HTTP upload.
|
|
99
|
+
#
|
|
100
|
+
# NOTE: This is a stub implementation. A production version would:
|
|
101
|
+
# - Use Net::HTTP or similar with proper SSL/verify certificate
|
|
102
|
+
# - Handle retries and exponential backoff
|
|
103
|
+
# - Implement request signing/HMAC
|
|
104
|
+
# - Support compression (gzip)
|
|
105
|
+
# - Set proper Content-Type and User-Agent headers
|
|
106
|
+
# - Implement request timeouts and circuit breaks
|
|
107
|
+
#
|
|
108
|
+
# For now, this is a documentation template.
|
|
109
|
+
def perform_upload(json_report, report_data, metadata)
|
|
110
|
+
require 'net/http'
|
|
111
|
+
require 'json'
|
|
112
|
+
|
|
113
|
+
url = build_upload_url(report_data)
|
|
114
|
+
request_body = build_request_body(json_report, metadata)
|
|
115
|
+
|
|
116
|
+
# Stub: Would make the actual HTTP request here
|
|
117
|
+
# This is intentionally not implemented to prevent accidental
|
|
118
|
+
# API calls during testing or misconfiguration.
|
|
119
|
+
#
|
|
120
|
+
# A production implementation would do something like:
|
|
121
|
+
# uri = URI(url)
|
|
122
|
+
# http = Net::HTTP.new(uri.host, uri.port)
|
|
123
|
+
# http.use_ssl = uri.scheme == 'https'
|
|
124
|
+
# req = Net::HTTP::Post.new(uri.path)
|
|
125
|
+
# req['Authorization'] = "Bearer #{@api_token}"
|
|
126
|
+
# req['Content-Type'] = 'application/json'
|
|
127
|
+
# response = http.request(req, request_body)
|
|
128
|
+
#
|
|
129
|
+
# But we leave that for when the SaaS platform exists.
|
|
130
|
+
|
|
131
|
+
# For now, return a success result indicating what WOULD be sent
|
|
132
|
+
UploadResult.new(
|
|
133
|
+
success: true,
|
|
134
|
+
uploader_name: name,
|
|
135
|
+
details: {
|
|
136
|
+
endpoint: url,
|
|
137
|
+
bytes_sent: request_body.bytesize,
|
|
138
|
+
report_version: report_data['report_version'],
|
|
139
|
+
project_key: @project_key,
|
|
140
|
+
note: 'HTTP uploader is configured but SaaS platform is not yet live'
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Build the upload endpoint URL.
|
|
146
|
+
def build_upload_url(report_data)
|
|
147
|
+
report_type = report_data['report_type'] || 'analysis'
|
|
148
|
+
base_url = @api_base_url.end_with?('/') ? @api_base_url[0...-1] : @api_base_url
|
|
149
|
+
"#{base_url}/api/v1/projects/#{@project_key}/reports/#{report_type}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Build the request body with headers and payload.
|
|
153
|
+
def build_request_body(json_report, metadata)
|
|
154
|
+
{
|
|
155
|
+
report: JSON.parse(json_report),
|
|
156
|
+
client_metadata: {
|
|
157
|
+
tool_version: QueryGuard::VERSION || 'unknown',
|
|
158
|
+
client_time: Time.now.utc.iso8601,
|
|
159
|
+
trace_id: metadata[:trace_id],
|
|
160
|
+
request_id: metadata[:request_id]
|
|
161
|
+
}
|
|
162
|
+
}.to_json
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QueryGuard
|
|
4
|
+
module Uploader
|
|
5
|
+
# Abstract interface for report uploaders.
|
|
6
|
+
#
|
|
7
|
+
# All uploaders must implement this interface. This allows:
|
|
8
|
+
# - Pluggable upload strategies (no-op, HTTP, webhook, etc.)
|
|
9
|
+
# - Easy testing with mock uploaders
|
|
10
|
+
# - Future extensibility without changing core code
|
|
11
|
+
#
|
|
12
|
+
# Example:
|
|
13
|
+
# uploader = QueryGuard::Uploader.for_config(config)
|
|
14
|
+
# result = uploader.upload(report_json)
|
|
15
|
+
class Interface
|
|
16
|
+
# Upload a JSON report to the configured destination.
|
|
17
|
+
#
|
|
18
|
+
# @param json_report [String] JSON-formatted report string
|
|
19
|
+
# @param metadata [Hash] Optional metadata (report_id, trace_id, etc.)
|
|
20
|
+
#
|
|
21
|
+
# @return [UploadResult] Result object with status and details
|
|
22
|
+
#
|
|
23
|
+
# Should never raise. Always return an UploadResult with success/failure state.
|
|
24
|
+
def upload(json_report, metadata = {})
|
|
25
|
+
raise NotImplementedError, "#{self.class} must implement #upload"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Check if this uploader is properly configured and ready.
|
|
29
|
+
#
|
|
30
|
+
# @return [Boolean] true if ready to upload, false otherwise
|
|
31
|
+
def ready?
|
|
32
|
+
raise NotImplementedError, "#{self.class} must implement #ready?"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Human-readable name of this uploader.
|
|
36
|
+
#
|
|
37
|
+
# @return [String]
|
|
38
|
+
def name
|
|
39
|
+
raise NotImplementedError, "#{self.class} must implement #name"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Get status/diagnostic information for logging.
|
|
43
|
+
#
|
|
44
|
+
# @return [Hash] Status information (target_url, enabled, etc.)
|
|
45
|
+
def status
|
|
46
|
+
raise NotImplementedError, "#{self.class} must implement #status"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Result of an upload operation.
|
|
51
|
+
class UploadResult
|
|
52
|
+
attr_reader :success, :uploader_name, :details, :error
|
|
53
|
+
|
|
54
|
+
def initialize(success:, uploader_name:, details: {}, error: nil)
|
|
55
|
+
@success = success
|
|
56
|
+
@uploader_name = uploader_name
|
|
57
|
+
@details = details
|
|
58
|
+
@error = error
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def successful?
|
|
62
|
+
@success
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def failed?
|
|
66
|
+
!@success
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_h
|
|
70
|
+
{
|
|
71
|
+
success: @success,
|
|
72
|
+
uploader: @uploader_name,
|
|
73
|
+
details: @details,
|
|
74
|
+
error: @error
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|