dbviewer 0.7.3 → 0.7.4
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/README.md +11 -6
- data/app/views/dbviewer/tables/query.html.erb +0 -7
- data/lib/dbviewer/cache/base.rb +32 -0
- data/lib/dbviewer/cache/in_memory.rb +44 -0
- data/lib/dbviewer/database/dynamic_model_factory.rb +4 -6
- data/lib/dbviewer/database/manager.rb +1 -12
- data/lib/dbviewer/database/metadata_manager.rb +20 -27
- data/lib/dbviewer/query/logger.rb +1 -1
- data/lib/dbviewer/query/notification_subscriber.rb +1 -4
- data/lib/dbviewer/query/parser.rb +13 -0
- data/lib/dbviewer/validator/sql/query_normalizer.rb +63 -0
- data/lib/dbviewer/validator/sql/threat_detector.rb +133 -0
- data/lib/dbviewer/validator/sql/validation_config.rb +74 -0
- data/lib/dbviewer/validator/sql/validation_result.rb +38 -0
- data/lib/dbviewer/validator/sql.rb +162 -150
- data/lib/dbviewer/version.rb +1 -1
- data/lib/dbviewer.rb +3 -1
- metadata +8 -3
- data/lib/dbviewer/database/cache_manager.rb +0 -78
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 243466a78ac9698ebe99e2f1cc225355bcb278eced6d87da6ca6d4cc87a636ba
|
4
|
+
data.tar.gz: eb7006f0fb710d799f9167f1f8a8081f6850c46e9bc9ed8d40245ca16024ffdc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f41b5f67b69a22e98a008c3e686c91a369428dc2d57beab21c429cf6ff9dca371cfa546c91a063db62e109819b4c3ff3ba1a6a3e0a333cf55f6e8e7d6f318ead
|
7
|
+
data.tar.gz: fd3467c65880b1f5b187537b7a10bdac6b971846704da02212f1dd04bf8f73c0f1d2ce355310f804c8fcb56d3f04da64ef9bca562027bf60958fc400bc1ee6b9
|
data/README.md
CHANGED
@@ -340,11 +340,15 @@ graph TB
|
|
340
340
|
|
341
341
|
subgraph "Database Namespace"
|
342
342
|
Manager[Manager<br/>Database Operations]
|
343
|
-
CacheManager[CacheManager<br/>Caching Layer]
|
344
343
|
MetadataManager[MetadataManager<br/>Schema Information]
|
345
344
|
DynamicModelFactory[DynamicModelFactory<br/>ActiveRecord Models]
|
346
345
|
end
|
347
346
|
|
347
|
+
subgraph "Cache Namespace"
|
348
|
+
CacheBase[Base<br/>Cache Interface]
|
349
|
+
InMemoryCache[InMemory<br/>In-Memory Cache Storage]
|
350
|
+
end
|
351
|
+
|
348
352
|
subgraph "Query Namespace"
|
349
353
|
QueryExecutor[Executor<br/>SQL Execution]
|
350
354
|
QueryLogger[Logger<br/>Query Logging]
|
@@ -371,7 +375,7 @@ graph TB
|
|
371
375
|
|
372
376
|
%% Configuration Dependencies (Decoupled)
|
373
377
|
Config -.->|"Dependency Injection"| Manager
|
374
|
-
Manager -->|"cache_expiry"|
|
378
|
+
Manager -->|"cache_expiry"| InMemoryCache
|
375
379
|
Manager -->|"config object"| QueryExecutor
|
376
380
|
|
377
381
|
%% Engine Initialization
|
@@ -394,14 +398,14 @@ graph TB
|
|
394
398
|
DatabaseOperations --> QueryOperations
|
395
399
|
|
396
400
|
%% Manager Dependencies
|
397
|
-
Manager -->
|
401
|
+
Manager --> InMemoryCache
|
398
402
|
Manager --> MetadataManager
|
399
403
|
Manager --> DynamicModelFactory
|
400
404
|
Manager --> QueryOperations
|
401
405
|
|
402
406
|
%% Cache Dependencies
|
403
|
-
|
404
|
-
|
407
|
+
InMemoryCache --> DynamicModelFactory
|
408
|
+
InMemoryCache --> MetadataManager
|
405
409
|
|
406
410
|
%% QueryOperations Dependencies (Refactored)
|
407
411
|
QueryOperations --> DynamicModelFactory
|
@@ -422,10 +426,11 @@ graph TB
|
|
422
426
|
ValidatorSql --> QueryExecutor
|
423
427
|
|
424
428
|
%% Styling
|
425
|
-
class
|
429
|
+
class InMemoryCache,QueryLogger decoupled
|
426
430
|
class HomeController,TablesController,LogsController,ERDController,APIController,ConnectionsController controller
|
427
431
|
class DatabaseOperations concern
|
428
432
|
class Manager,MetadataManager,DynamicModelFactory database
|
433
|
+
class CacheBase,InMemoryCache cache
|
429
434
|
class QueryExecutor,QueryAnalyzer,QueryParser,NotificationSubscriber query
|
430
435
|
class StorageBase,InMemoryStorage,FileStorage storage
|
431
436
|
class ColumnFiltering filtering
|
@@ -17,13 +17,6 @@
|
|
17
17
|
</div>
|
18
18
|
</div>
|
19
19
|
|
20
|
-
<% if flash[:warning].present? %>
|
21
|
-
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
22
|
-
<%= flash[:warning] %>
|
23
|
-
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
24
|
-
</div>
|
25
|
-
<% end %>
|
26
|
-
|
27
20
|
<div class="card mb-4">
|
28
21
|
<div class="card-header">
|
29
22
|
<h5>SQL Query (Read-Only)</h5>
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
module Cache
|
3
|
+
# Base handles caching concerns for the DatabaseManager
|
4
|
+
# It provides an abstraction layer for managing caches efficiently
|
5
|
+
class Base
|
6
|
+
# Initialize the cache manager
|
7
|
+
# @param cache_expiry [Integer] Cache expiration time in seconds (default: 300)
|
8
|
+
def initialize(cache_expiry = 300)
|
9
|
+
@cache_expiry = cache_expiry
|
10
|
+
@unified_cache = {}
|
11
|
+
@cache_last_reset = Time.now
|
12
|
+
end
|
13
|
+
|
14
|
+
# Fetch data from cache or execute block if not found/expired
|
15
|
+
# @param key [String] Cache key
|
16
|
+
# @param options [Hash] Options for the cache entry
|
17
|
+
# @option options [Integer] :expires_in Custom expiry time in seconds
|
18
|
+
# @yield Block to execute if cache miss or expired
|
19
|
+
# @return [Object] Cached value or result of block execution
|
20
|
+
def fetch(key, options = {}, &block)
|
21
|
+
raise NotImplementedError, "#{self.class}#fetch must be implemented by a subclass"
|
22
|
+
end
|
23
|
+
|
24
|
+
# Delete a specific cache entry by key
|
25
|
+
# @param key [String] Cache key to delete
|
26
|
+
# @return [Object, nil] The deleted value or nil if not found
|
27
|
+
def delete(key)
|
28
|
+
raise NotImplementedError, "#{self.class}#delete must be implemented by a subclass"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
module Cache
|
3
|
+
# InMemory cache storage for Dbviewer
|
4
|
+
# It provides an abstraction layer for managing caches efficiently
|
5
|
+
class InMemory < Dbviewer::Cache::Base
|
6
|
+
# Fetch data from cache or execute block if not found/expired
|
7
|
+
# @param key [String] Cache key
|
8
|
+
# @param options [Hash] Options for the cache entry
|
9
|
+
# @option options [Integer] :expires_in Custom expiry time in seconds
|
10
|
+
# @yield Block to execute if cache miss or expired
|
11
|
+
# @return [Object] Cached value or result of block execution
|
12
|
+
def fetch(key, options = {}, &block)
|
13
|
+
cache_entry = @unified_cache[key]
|
14
|
+
custom_expiry = options[:expires_in] || @cache_expiry
|
15
|
+
return cache_entry[:value] if cache_entry && !cache_expired?(cache_entry, custom_expiry)
|
16
|
+
|
17
|
+
result = block.call
|
18
|
+
@unified_cache[key] = {
|
19
|
+
value: result,
|
20
|
+
created_at: Time.now
|
21
|
+
}
|
22
|
+
result
|
23
|
+
end
|
24
|
+
|
25
|
+
# Delete a specific cache entry by key
|
26
|
+
# @param key [String] Cache key to delete
|
27
|
+
# @return [Object, nil] The deleted value or nil if not found
|
28
|
+
def delete(key)
|
29
|
+
deleted_entry = @unified_cache.delete(key)
|
30
|
+
deleted_entry&.fetch(:value)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
# Check if a cache entry is expired
|
36
|
+
# @param cache_entry [Hash] Cache entry with :created_at timestamp
|
37
|
+
# @param expiry_time [Integer] Expiry time in seconds
|
38
|
+
# @return [Boolean] True if expired, false otherwise
|
39
|
+
def cache_expired?(cache_entry, expiry_time)
|
40
|
+
Time.now - cache_entry[:created_at] > expiry_time
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -14,12 +14,10 @@ module Dbviewer
|
|
14
14
|
# @param table_name [String] Name of the table
|
15
15
|
# @return [Class] ActiveRecord model class for the table
|
16
16
|
def get_model_for(table_name)
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
@cache_manager.store_model(table_name, model)
|
22
|
-
model
|
17
|
+
# Cache models for shorter time since they might need refreshing more frequently
|
18
|
+
@cache_manager.fetch("model-#{table_name}", expires_in: 300) do
|
19
|
+
create_model_for(table_name)
|
20
|
+
end
|
23
21
|
end
|
24
22
|
|
25
23
|
private
|
@@ -10,7 +10,7 @@ module Dbviewer
|
|
10
10
|
def initialize(connection_key = nil)
|
11
11
|
@connection_key = connection_key || Dbviewer.configuration.current_connection
|
12
12
|
ensure_connection
|
13
|
-
@cache_manager = ::Dbviewer::
|
13
|
+
@cache_manager = ::Dbviewer::Cache::InMemory.new(configuration.cache_expiry)
|
14
14
|
@table_metadata_manager = ::Dbviewer::Database::MetadataManager.new(@connection, @cache_manager)
|
15
15
|
@dynamic_model_factory = ::Dbviewer::Database::DynamicModelFactory.new(@connection, @cache_manager)
|
16
16
|
@query_executor = ::Dbviewer::Query::Executor.new(@connection, configuration)
|
@@ -19,7 +19,6 @@ module Dbviewer
|
|
19
19
|
@dynamic_model_factory,
|
20
20
|
@table_metadata_manager
|
21
21
|
)
|
22
|
-
reset_cache_if_needed
|
23
22
|
end
|
24
23
|
|
25
24
|
# Get configuration from class method or Dbviewer
|
@@ -132,11 +131,6 @@ module Dbviewer
|
|
132
131
|
@table_metadata_manager.fetch_foreign_keys(table_name)
|
133
132
|
end
|
134
133
|
|
135
|
-
# Clear all caches - useful when schema changes are detected
|
136
|
-
def clear_all_caches
|
137
|
-
@cache_manager.clear_all
|
138
|
-
end
|
139
|
-
|
140
134
|
# Calculate the total size of the database schema
|
141
135
|
# @return [Integer, nil] Database size in bytes or nil if unsupported
|
142
136
|
def fetch_schema_size
|
@@ -202,11 +196,6 @@ module Dbviewer
|
|
202
196
|
@adapter_name = @connection.adapter_name.downcase
|
203
197
|
@connection
|
204
198
|
end
|
205
|
-
|
206
|
-
# Reset caches if they've been around too long
|
207
|
-
def reset_cache_if_needed
|
208
|
-
@cache_manager.reset_if_needed
|
209
|
-
end
|
210
199
|
end
|
211
200
|
end
|
212
201
|
end
|
@@ -20,40 +20,33 @@ module Dbviewer
|
|
20
20
|
# @param table_name [String] Name of the table
|
21
21
|
# @return [Array<Hash>] List of column details
|
22
22
|
def table_columns(table_name)
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
23
|
+
# Cache columns for longer since schema changes are infrequent
|
24
|
+
@cache_manager.fetch("columns-#{table_name}", expires_in: 600) do
|
25
|
+
@connection.columns(table_name).map do |column|
|
26
|
+
{
|
27
|
+
name: column.name,
|
28
|
+
type: column.type,
|
29
|
+
null: column.null,
|
30
|
+
default: column.default,
|
31
|
+
primary: column.name == primary_key(table_name)
|
32
|
+
}
|
33
|
+
end
|
34
34
|
end
|
35
|
-
|
36
|
-
# Cache the result
|
37
|
-
@cache_manager.store_columns(table_name, columns)
|
38
|
-
columns
|
39
35
|
end
|
40
36
|
|
41
37
|
# Get detailed metadata about a table
|
42
38
|
# @param table_name [String] Name of the table
|
43
39
|
# @return [Hash] Table metadata
|
44
40
|
def table_metadata(table_name)
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
@cache_manager.store_metadata(table_name, metadata)
|
56
|
-
metadata
|
41
|
+
# Cache metadata for longer since schema relationships change infrequently
|
42
|
+
@cache_manager.fetch("metadata-#{table_name}", expires_in: 600) do
|
43
|
+
{
|
44
|
+
primary_key: primary_key(table_name),
|
45
|
+
indexes: fetch_indexes(table_name),
|
46
|
+
foreign_keys: fetch_foreign_keys(table_name),
|
47
|
+
reverse_foreign_keys: fetch_reverse_foreign_keys(table_name)
|
48
|
+
}
|
49
|
+
end
|
57
50
|
end
|
58
51
|
|
59
52
|
# Get the primary key of a table
|
@@ -32,7 +32,7 @@ module Dbviewer
|
|
32
32
|
def add(event)
|
33
33
|
# Return early if query logging is disabled
|
34
34
|
return unless @enable_query_logging
|
35
|
-
return if ::Dbviewer::Query::Parser.should_skip_query?(event)
|
35
|
+
return if ::Dbviewer::Query::Parser.should_skip_query?(event) || ::Dbviewer::Query::Parser.should_skip_internal_query?(event)
|
36
36
|
|
37
37
|
current_time = Time.now
|
38
38
|
@storage.add({
|
@@ -21,9 +21,6 @@ module Dbviewer
|
|
21
21
|
# @param args [Array] Notification arguments from ActiveSupport::Notifications
|
22
22
|
def process_notification(*args)
|
23
23
|
event = ActiveSupport::Notifications::Event.new(*args)
|
24
|
-
|
25
|
-
return if skip_internal_query?(event)
|
26
|
-
|
27
24
|
Dbviewer::Query::Logger.instance.add(event)
|
28
25
|
end
|
29
26
|
|
@@ -35,7 +32,7 @@ module Dbviewer
|
|
35
32
|
return false unless caller_locations
|
36
33
|
|
37
34
|
excluded_caller_locations = caller_locations.filter do |caller_location|
|
38
|
-
!caller_location.path.include?("lib/dbviewer/
|
35
|
+
!caller_location.path.include?("lib/dbviewer/query/notification_subscriber.rb")
|
39
36
|
end
|
40
37
|
|
41
38
|
excluded_caller_locations.any? { |location| location.path.include?("dbviewer") }
|
@@ -42,6 +42,8 @@ module Dbviewer
|
|
42
42
|
|
43
43
|
# Determine if a query should be skipped based on content
|
44
44
|
# Rails and ActiveRecord often run internal queries that are not useful for logging
|
45
|
+
# @param event [ActiveSupport::Notifications::Event] The notification event
|
46
|
+
# @return [Boolean] True if the query should be skipped
|
45
47
|
def self.should_skip_query?(event)
|
46
48
|
event.payload[:name] == "SCHEMA" ||
|
47
49
|
event.payload[:sql].include?("SHOW TABLES") ||
|
@@ -51,6 +53,17 @@ module Dbviewer
|
|
51
53
|
event.payload[:sql].include?("ar_internal_metadata") ||
|
52
54
|
event.payload[:sql].include?("pg_catalog")
|
53
55
|
end
|
56
|
+
|
57
|
+
# Determine if an internal query should be skipped
|
58
|
+
# Internal queries are those that are part of the Dbviewer module itself
|
59
|
+
# and do not represent user-generated SQL queries.
|
60
|
+
# This helps avoid logging internal operations that are not relevant to users.
|
61
|
+
# @param event [ActiveSupport::Notifications::Event] The notification event
|
62
|
+
# @return [Boolean] True if the query should be skipped
|
63
|
+
def self.should_skip_internal_query?(event)
|
64
|
+
event.payload[:name].include?("Dbviewer::") ||
|
65
|
+
event.payload[:sql].include?("PRAGMA")
|
66
|
+
end
|
54
67
|
end
|
55
68
|
end
|
56
69
|
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dbviewer
|
4
|
+
module Validator
|
5
|
+
class Sql
|
6
|
+
# Module for query normalization operations
|
7
|
+
# This module handles the cleaning and standardization of SQL queries
|
8
|
+
# to prepare them for validation and threat detection.
|
9
|
+
module QueryNormalizer
|
10
|
+
extend self
|
11
|
+
|
12
|
+
# Normalize SQL by removing comments and extra whitespace
|
13
|
+
# This prepares the query for consistent validation by:
|
14
|
+
# - Removing SQL comments (both -- and /* */ styles)
|
15
|
+
# - Normalizing whitespace to single spaces
|
16
|
+
# - Trimming leading/trailing whitespace
|
17
|
+
#
|
18
|
+
# @param sql [String] The SQL query to normalize
|
19
|
+
# @return [String] The normalized SQL query
|
20
|
+
def normalize(sql)
|
21
|
+
return "" if sql.nil?
|
22
|
+
|
23
|
+
begin
|
24
|
+
normalized = remove_comments(sql)
|
25
|
+
normalized = normalize_whitespace(normalized)
|
26
|
+
normalized.strip
|
27
|
+
rescue => e
|
28
|
+
# Log error if Rails logger is available, otherwise use basic error handling
|
29
|
+
if defined?(Rails) && Rails.respond_to?(:logger)
|
30
|
+
Rails.logger.error("[DBViewer] SQL normalization error: #{e.message}")
|
31
|
+
else
|
32
|
+
# Fallback to stderr if Rails is not available
|
33
|
+
$stderr.puts "[DBViewer] SQL normalization error: #{e.message}"
|
34
|
+
end
|
35
|
+
""
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
# Remove SQL comments from the query
|
42
|
+
# Handles both single-line (--) and multi-line (/* */) comment styles
|
43
|
+
#
|
44
|
+
# @param sql [String] The SQL query
|
45
|
+
# @return [String] SQL with comments removed
|
46
|
+
def remove_comments(sql)
|
47
|
+
sql.gsub(/--.*$/, "") # Remove -- style comments
|
48
|
+
.gsub(/\/\*.*?\*\//m, "") # Remove /* */ style comments
|
49
|
+
end
|
50
|
+
|
51
|
+
# Normalize whitespace in the SQL query
|
52
|
+
# Converts all whitespace sequences to single spaces for consistent parsing
|
53
|
+
#
|
54
|
+
# @param sql [String] The SQL query
|
55
|
+
# @return [String] SQL with normalized whitespace
|
56
|
+
def normalize_whitespace(sql)
|
57
|
+
sql.gsub(/\s+/, " ") # Normalize whitespace
|
58
|
+
.gsub(/\s{2,}/, " ") # Replace multiple spaces with single space
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "validation_config"
|
4
|
+
|
5
|
+
module Dbviewer
|
6
|
+
module Validator
|
7
|
+
class Sql
|
8
|
+
# Module for detecting various types of security threats in SQL queries
|
9
|
+
# This module contains all the logic for identifying SQL injection attempts,
|
10
|
+
# suspicious patterns, and other security vulnerabilities.
|
11
|
+
module ThreatDetector
|
12
|
+
extend self
|
13
|
+
|
14
|
+
# Check for suspicious patterns in SQL that might indicate an attack
|
15
|
+
# This method performs multiple checks on the raw SQL (before normalization)
|
16
|
+
# to catch threats that might be hidden by the normalization process.
|
17
|
+
#
|
18
|
+
# @param sql [String] Raw SQL query (before normalization)
|
19
|
+
# @return [Boolean] true if suspicious patterns found, false otherwise
|
20
|
+
def has_suspicious_patterns?(sql)
|
21
|
+
return true if has_comment_injection?(sql)
|
22
|
+
return true if has_string_concatenation?(sql)
|
23
|
+
return true if has_excessive_quotes?(sql)
|
24
|
+
return true if has_hex_encoding?(sql)
|
25
|
+
|
26
|
+
false
|
27
|
+
end
|
28
|
+
|
29
|
+
# Check for specific SQL injection patterns
|
30
|
+
# This method looks for known SQL injection attack patterns
|
31
|
+
# that are commonly used by attackers.
|
32
|
+
#
|
33
|
+
# @param sql [String] Raw SQL query (before normalization)
|
34
|
+
# @return [Boolean] true if injection patterns found, false otherwise
|
35
|
+
def has_injection_patterns?(sql)
|
36
|
+
ValidationConfig::INJECTION_PATTERNS.any? do |_name, pattern|
|
37
|
+
sql =~ pattern
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Check for forbidden keywords in the normalized SQL
|
42
|
+
# This method scans for keywords that could modify data or schema.
|
43
|
+
#
|
44
|
+
# @param normalized_sql [String] Normalized SQL query
|
45
|
+
# @return [String, nil] The forbidden keyword found, or nil if none
|
46
|
+
def detect_forbidden_keywords(normalized_sql)
|
47
|
+
ValidationConfig::FORBIDDEN_KEYWORDS.find do |keyword|
|
48
|
+
normalized_sql =~ /\b#{keyword}\b/i
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Check if the query starts with an allowed statement type
|
53
|
+
# Only SELECT and WITH (for CTEs) are allowed as query starters.
|
54
|
+
#
|
55
|
+
# @param normalized_sql [String] Normalized SQL query
|
56
|
+
# @return [Boolean] true if query starts with allowed statement
|
57
|
+
def valid_query_start?(normalized_sql)
|
58
|
+
normalized_sql =~ ValidationConfig::VALID_QUERY_START_PATTERN
|
59
|
+
end
|
60
|
+
|
61
|
+
# Check if the query is a SQLite PRAGMA statement
|
62
|
+
# PRAGMA statements are allowed for database introspection.
|
63
|
+
#
|
64
|
+
# @param normalized_sql [String] Normalized SQL query
|
65
|
+
# @return [Boolean] true if query is a PRAGMA statement
|
66
|
+
def pragma_statement?(normalized_sql)
|
67
|
+
normalized_sql =~ ValidationConfig::PRAGMA_PATTERN
|
68
|
+
end
|
69
|
+
|
70
|
+
# Check for multiple SQL statements separated by semicolons
|
71
|
+
# Multiple statements could allow SQL injection attacks.
|
72
|
+
#
|
73
|
+
# @param normalized_sql [String] Normalized SQL query
|
74
|
+
# @return [Boolean] true if multiple statements detected
|
75
|
+
def has_multiple_statements?(normalized_sql)
|
76
|
+
statements = normalized_sql.split(";").reject(&:blank?)
|
77
|
+
statements.size > 1
|
78
|
+
end
|
79
|
+
|
80
|
+
# Detect subqueries in the SQL by checking for unbalanced parentheses
|
81
|
+
# This is a heuristic method for feature detection.
|
82
|
+
#
|
83
|
+
# @param normalized_sql [String] Normalized SQL query
|
84
|
+
# @return [Boolean] true if subqueries are likely present
|
85
|
+
def detect_subqueries(normalized_sql)
|
86
|
+
# Check if there are unbalanced parentheses that likely contain subqueries
|
87
|
+
normalized_sql.count("(") > normalized_sql.count(")")
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
# Check for comment injection attempts
|
93
|
+
# Comments can be used to hide malicious SQL code
|
94
|
+
#
|
95
|
+
# @param sql [String] Raw SQL query
|
96
|
+
# @return [Boolean] true if comment injection detected
|
97
|
+
def has_comment_injection?(sql)
|
98
|
+
ValidationConfig::SUSPICIOUS_PATTERNS[:comment_injection] =~ sql
|
99
|
+
end
|
100
|
+
|
101
|
+
# Check for string concatenation that might be used for injection
|
102
|
+
# String concatenation can be used to build dynamic SQL attacks
|
103
|
+
#
|
104
|
+
# @param sql [String] Raw SQL query
|
105
|
+
# @return [Boolean] true if suspicious string concatenation detected
|
106
|
+
def has_string_concatenation?(sql)
|
107
|
+
ValidationConfig::SUSPICIOUS_PATTERNS[:string_concatenation] =~ sql
|
108
|
+
end
|
109
|
+
|
110
|
+
# Check for excessive quotes which might indicate injection attempts
|
111
|
+
# Large numbers of quotes can indicate SQL injection payload construction
|
112
|
+
#
|
113
|
+
# @param sql [String] Raw SQL query
|
114
|
+
# @return [Boolean] true if excessive quotes detected
|
115
|
+
def has_excessive_quotes?(sql)
|
116
|
+
single_quotes = sql.count("'")
|
117
|
+
double_quotes = sql.count('"')
|
118
|
+
single_quotes > ValidationConfig::QUOTE_LIMIT ||
|
119
|
+
double_quotes > ValidationConfig::QUOTE_LIMIT
|
120
|
+
end
|
121
|
+
|
122
|
+
# Check for hex encoding that might hide malicious code
|
123
|
+
# Long hex strings can be used to encode SQL injection payloads
|
124
|
+
#
|
125
|
+
# @param sql [String] Raw SQL query
|
126
|
+
# @return [Boolean] true if suspicious hex encoding detected
|
127
|
+
def has_hex_encoding?(sql)
|
128
|
+
ValidationConfig::SUSPICIOUS_PATTERNS[:hex_encoding] =~ sql
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dbviewer
|
4
|
+
module Validator
|
5
|
+
class Sql
|
6
|
+
# Configuration constants and patterns for SQL validation
|
7
|
+
# This module centralizes all security-related patterns and rules
|
8
|
+
# used throughout the validation process.
|
9
|
+
module ValidationConfig
|
10
|
+
# List of SQL keywords that could modify data or schema
|
11
|
+
# These keywords are completely forbidden in user queries
|
12
|
+
FORBIDDEN_KEYWORDS = %w[
|
13
|
+
UPDATE INSERT DELETE DROP ALTER CREATE TRUNCATE REPLACE
|
14
|
+
RENAME GRANT REVOKE LOCK UNLOCK COMMIT ROLLBACK
|
15
|
+
SAVEPOINT INTO CALL EXECUTE EXEC
|
16
|
+
].freeze
|
17
|
+
|
18
|
+
# List of SQL keywords that should only be allowed in specific contexts
|
19
|
+
# These are monitored but not automatically blocked
|
20
|
+
CONDITIONAL_KEYWORDS = {
|
21
|
+
# JOIN is allowed, but we should check for suspicious patterns
|
22
|
+
"JOIN" => /\bJOIN\b/i,
|
23
|
+
# UNION is allowed, but potential for injection
|
24
|
+
"UNION" => /\bUNION\b/i,
|
25
|
+
# WITH is allowed for CTEs, but need to ensure it's not a data modification
|
26
|
+
"WITH" => /\bWITH\b/i
|
27
|
+
}.freeze
|
28
|
+
|
29
|
+
# Maximum allowed query length (can be overridden by configuration)
|
30
|
+
DEFAULT_MAX_QUERY_LENGTH = 10000
|
31
|
+
|
32
|
+
# Patterns for detecting suspicious content that might indicate attacks
|
33
|
+
SUSPICIOUS_PATTERNS = {
|
34
|
+
comment_injection: /\s+--|\/\*/,
|
35
|
+
string_concatenation: /\|\||CONCAT\s*\(/i,
|
36
|
+
hex_encoding: /0x[0-9a-f]{16,}/i
|
37
|
+
}.freeze
|
38
|
+
|
39
|
+
# SQL injection attack patterns - these are definitive threats
|
40
|
+
INJECTION_PATTERNS = {
|
41
|
+
basic_or_injection: /'\s*OR\s*'.*'\s*=\s*'/i,
|
42
|
+
quoted_or_equals: /'\s*OR\s*1\s*=\s*1/i,
|
43
|
+
unquoted_or_equals: /\s+OR\s+1\s*=\s*1/i,
|
44
|
+
comment_termination: /'\s*;\s*--/i,
|
45
|
+
version_detection: /@@version/i,
|
46
|
+
version_function: /version\(\)/i,
|
47
|
+
file_access: /\bLOAD_FILE\s*\(/i,
|
48
|
+
outfile_access: /\bINTO\s+OUTFILE\b/i,
|
49
|
+
dumpfile_access: /\bINTO\s+DUMPFILE\b/i
|
50
|
+
}.freeze
|
51
|
+
|
52
|
+
# Database feature detection patterns for query analysis
|
53
|
+
FEATURE_PATTERNS = {
|
54
|
+
join: /\b(INNER|LEFT|RIGHT|FULL|CROSS)?\s*JOIN\b/i,
|
55
|
+
order_by: /\bORDER\s+BY\b/i,
|
56
|
+
group_by: /\bGROUP\s+BY\b/i,
|
57
|
+
having: /\bHAVING\b/i,
|
58
|
+
union: /\bUNION\b/i,
|
59
|
+
window_function: /\bOVER\s*\(/i
|
60
|
+
}.freeze
|
61
|
+
|
62
|
+
# Thresholds for suspicious activity detection
|
63
|
+
QUOTE_LIMIT = 20 # Maximum number of quotes before flagging as suspicious
|
64
|
+
HEX_MIN_LENGTH = 16 # Minimum hex string length to be considered suspicious
|
65
|
+
|
66
|
+
# PRAGMA statement pattern for SQLite introspection
|
67
|
+
PRAGMA_PATTERN = /\A\s*PRAGMA\s+[a-z0-9_]+(\([^)]*\))?\s*\z/i
|
68
|
+
|
69
|
+
# Valid query start patterns (SELECT or WITH for CTEs)
|
70
|
+
VALID_QUERY_START_PATTERN = /\A\s*(SELECT|WITH)\s+/i
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dbviewer
|
4
|
+
module Validator
|
5
|
+
class Sql
|
6
|
+
# Validation result object to encapsulate validation state and errors
|
7
|
+
# This provides a structured way to return validation results with
|
8
|
+
# clear success/failure states and associated error messages.
|
9
|
+
ValidationResult = Struct.new(:valid?, :error_message, :normalized_sql, keyword_init: true) do
|
10
|
+
# Check if the validation was successful
|
11
|
+
# @return [Boolean] true if validation passed
|
12
|
+
def success?
|
13
|
+
valid?
|
14
|
+
end
|
15
|
+
|
16
|
+
# Check if the validation failed
|
17
|
+
# @return [Boolean] true if validation failed
|
18
|
+
def failure?
|
19
|
+
!valid?
|
20
|
+
end
|
21
|
+
|
22
|
+
# Create a successful validation result
|
23
|
+
# @param normalized_sql [String] The normalized SQL query
|
24
|
+
# @return [ValidationResult] Success result with normalized SQL
|
25
|
+
def self.success(normalized_sql)
|
26
|
+
new(valid?: true, normalized_sql: normalized_sql)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Create a failed validation result
|
30
|
+
# @param error_message [String] Description of the validation failure
|
31
|
+
# @return [ValidationResult] Failure result with error message
|
32
|
+
def self.failure(error_message)
|
33
|
+
new(valid?: false, error_message: error_message)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -1,196 +1,208 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "sql/validation_result"
|
4
|
+
require_relative "sql/validation_config"
|
5
|
+
require_relative "sql/query_normalizer"
|
6
|
+
require_relative "sql/threat_detector"
|
7
|
+
|
3
8
|
module Dbviewer
|
4
9
|
module Validator
|
5
10
|
# Sql class handles SQL query validation and normalization
|
6
11
|
# to ensure queries are safe (read-only) and properly formatted.
|
7
12
|
# This helps prevent potentially destructive SQL operations.
|
8
13
|
class Sql
|
9
|
-
#
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
CONDITIONAL_KEYWORDS = {
|
18
|
-
# JOIN is allowed, but we should check for suspicious patterns
|
19
|
-
"JOIN" => /\bJOIN\b/i,
|
20
|
-
# UNION is allowed, but potential for injection
|
21
|
-
"UNION" => /\bUNION\b/i,
|
22
|
-
# WITH is allowed for CTEs, but need to ensure it's not a data modification
|
23
|
-
"WITH" => /\bWITH\b/i
|
24
|
-
}
|
25
|
-
|
26
|
-
# Maximum allowed query length
|
27
|
-
MAX_QUERY_LENGTH = 10000
|
28
|
-
|
29
|
-
# Determines if a query is safe (read-only)
|
30
|
-
# @param sql [String] The SQL query to validate
|
31
|
-
# @return [Boolean] true if the query is safe, false otherwise
|
32
|
-
def self.safe_query?(sql)
|
33
|
-
return false if sql.blank?
|
34
|
-
|
35
|
-
# Get max query length from configuration
|
36
|
-
max_length = Dbviewer.configuration.max_query_length || MAX_QUERY_LENGTH
|
37
|
-
return false if sql.length > max_length
|
38
|
-
|
39
|
-
normalized_sql = normalize(sql)
|
40
|
-
|
41
|
-
# Case-insensitive check for SELECT at the beginning
|
42
|
-
return false unless normalized_sql =~ /\A\s*SELECT\s+/i
|
43
|
-
|
44
|
-
# Check for forbidden keywords that might be used in subqueries or other SQL constructs
|
45
|
-
FORBIDDEN_KEYWORDS.each do |keyword|
|
46
|
-
# Look for the keyword with word boundaries to avoid false positives
|
47
|
-
return false if normalized_sql =~ /\b#{keyword}\b/i
|
14
|
+
# Core validation methods
|
15
|
+
class << self
|
16
|
+
# Determines if a query is safe (read-only)
|
17
|
+
# @param sql [String] The SQL query to validate
|
18
|
+
# @return [Boolean] true if the query is safe, false otherwise
|
19
|
+
def safe_query?(sql)
|
20
|
+
result = validate_query(sql, allow_pragma: false)
|
21
|
+
result.success?
|
48
22
|
end
|
49
23
|
|
50
|
-
#
|
51
|
-
|
24
|
+
# Validates a query and raises an exception if it's unsafe
|
25
|
+
# @param sql [String] The SQL query to validate
|
26
|
+
# @raise [SecurityError] if the query is unsafe
|
27
|
+
# @return [String] The normalized SQL query if it's safe
|
28
|
+
def validate!(sql)
|
29
|
+
result = validate_query(sql, allow_pragma: true)
|
52
30
|
|
53
|
-
|
54
|
-
|
55
|
-
|
31
|
+
if result.failure?
|
32
|
+
raise SecurityError, result.error_message
|
33
|
+
end
|
56
34
|
|
57
|
-
|
58
|
-
|
35
|
+
result.normalized_sql
|
36
|
+
end
|
59
37
|
|
60
|
-
|
61
|
-
end
|
38
|
+
private
|
62
39
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
40
|
+
# Main validation logic that returns a structured result
|
41
|
+
# @param sql [String] The SQL query to validate
|
42
|
+
# @param allow_pragma [Boolean] Whether to allow PRAGMA statements
|
43
|
+
# @return [ValidationResult] Validation result with status and details
|
44
|
+
def validate_query(sql, allow_pragma: true)
|
45
|
+
# Step 1: Basic input validation
|
46
|
+
basic_validation_result = perform_basic_validation(sql)
|
47
|
+
return basic_validation_result if basic_validation_result
|
69
48
|
|
70
|
-
|
71
|
-
|
49
|
+
# Step 2: Security threat detection (before normalization)
|
50
|
+
threat_validation_result = perform_threat_validation(sql)
|
51
|
+
return threat_validation_result if threat_validation_result
|
72
52
|
|
73
|
-
|
74
|
-
|
75
|
-
double_quotes = sql.count('"')
|
76
|
-
return true if single_quotes > 20 || double_quotes > 20
|
53
|
+
# Step 3: Normalize the query
|
54
|
+
normalized_sql = QueryNormalizer.normalize(sql)
|
77
55
|
|
78
|
-
|
79
|
-
|
56
|
+
# Step 4: Handle special cases (PRAGMA) - only if allowed
|
57
|
+
if allow_pragma
|
58
|
+
pragma_result = handle_pragma_statements(normalized_sql)
|
59
|
+
return pragma_result if pragma_result
|
60
|
+
end
|
80
61
|
|
81
|
-
|
82
|
-
|
62
|
+
# Step 5: Validate query structure and keywords
|
63
|
+
structure_validation_result = perform_structure_validation(normalized_sql)
|
64
|
+
return structure_validation_result if structure_validation_result
|
83
65
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
def self.has_injection_patterns?(sql)
|
88
|
-
# Check for typical SQL injection test patterns
|
89
|
-
return true if sql =~ /'\s*OR\s*'.*'\s*=\s*'/i
|
90
|
-
return true if sql =~ /'\s*OR\s*1\s*=\s*1/i
|
91
|
-
return true if sql =~ /'\s*;\s*--/i
|
66
|
+
# Step 6: Check for multiple statements
|
67
|
+
multiple_statements_result = validate_single_statement(normalized_sql)
|
68
|
+
return multiple_statements_result if multiple_statements_result
|
92
69
|
|
93
|
-
|
94
|
-
|
95
|
-
|
70
|
+
# Success case
|
71
|
+
ValidationResult.new(
|
72
|
+
valid?: true,
|
73
|
+
normalized_sql: normalized_sql
|
74
|
+
)
|
75
|
+
end
|
96
76
|
|
97
|
-
|
98
|
-
|
77
|
+
# Perform basic input validation (null, empty, length)
|
78
|
+
def perform_basic_validation(sql)
|
79
|
+
if sql.nil? || sql.strip.empty?
|
80
|
+
return ValidationResult.new(
|
81
|
+
valid?: false,
|
82
|
+
error_message: "Empty query is not allowed"
|
83
|
+
)
|
84
|
+
end
|
85
|
+
|
86
|
+
max_length = get_max_query_length
|
87
|
+
if sql.length > max_length
|
88
|
+
return ValidationResult.new(
|
89
|
+
valid?: false,
|
90
|
+
error_message: "Query exceeds maximum allowed length (#{max_length} chars)"
|
91
|
+
)
|
92
|
+
end
|
99
93
|
|
100
|
-
|
101
|
-
# @param sql [String] The SQL query to normalize
|
102
|
-
# @return [String] The normalized SQL query
|
103
|
-
def self.normalize(sql)
|
104
|
-
return "" if sql.nil?
|
105
|
-
|
106
|
-
begin
|
107
|
-
# Remove SQL comments (both -- and /* */ styles)
|
108
|
-
normalized = sql.gsub(/--.*$/, "") # Remove -- style comments
|
109
|
-
.gsub(/\/\*.*?\*\//m, "") # Remove /* */ style comments
|
110
|
-
.gsub(/\s+/, " ") # Normalize whitespace
|
111
|
-
.strip # Remove leading/trailing whitespace
|
112
|
-
|
113
|
-
# Replace multiple spaces with a single space
|
114
|
-
normalized.gsub(/\s{2,}/, " ")
|
115
|
-
rescue => e
|
116
|
-
Rails.logger.error("[DBViewer] SQL normalization error: #{e.message}")
|
117
|
-
""
|
94
|
+
nil # No validation errors
|
118
95
|
end
|
119
|
-
end
|
120
96
|
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
97
|
+
# Perform security threat detection
|
98
|
+
def perform_threat_validation(sql)
|
99
|
+
if ThreatDetector.has_suspicious_patterns?(sql)
|
100
|
+
return ValidationResult.new(
|
101
|
+
valid?: false,
|
102
|
+
error_message: "Query contains suspicious patterns that may indicate SQL injection"
|
103
|
+
)
|
104
|
+
end
|
105
|
+
|
106
|
+
if ThreatDetector.has_injection_patterns?(sql)
|
107
|
+
return ValidationResult.new(
|
108
|
+
valid?: false,
|
109
|
+
error_message: "Query contains patterns commonly associated with SQL injection attempts"
|
110
|
+
)
|
111
|
+
end
|
112
|
+
|
113
|
+
nil # No security threats detected
|
128
114
|
end
|
129
115
|
|
130
|
-
#
|
131
|
-
|
132
|
-
|
133
|
-
|
116
|
+
# Handle special PRAGMA statements for SQLite
|
117
|
+
def handle_pragma_statements(normalized_sql)
|
118
|
+
if pragma_statement?(normalized_sql)
|
119
|
+
return ValidationResult.new(
|
120
|
+
valid?: true,
|
121
|
+
normalized_sql: normalized_sql
|
122
|
+
)
|
123
|
+
end
|
124
|
+
|
125
|
+
nil # Not a PRAGMA statement
|
134
126
|
end
|
135
127
|
|
136
|
-
|
128
|
+
# Validate query structure and forbidden keywords
|
129
|
+
def perform_structure_validation(normalized_sql)
|
130
|
+
unless valid_query_start?(normalized_sql)
|
131
|
+
return ValidationResult.new(
|
132
|
+
valid?: false,
|
133
|
+
error_message: "Query must begin with SELECT or WITH for security reasons"
|
134
|
+
)
|
135
|
+
end
|
137
136
|
|
138
|
-
|
139
|
-
|
140
|
-
|
137
|
+
forbidden_keyword = detect_forbidden_keywords(normalized_sql)
|
138
|
+
if forbidden_keyword
|
139
|
+
return ValidationResult.new(
|
140
|
+
valid?: false,
|
141
|
+
error_message: "Forbidden keyword '#{forbidden_keyword}' detected in query"
|
142
|
+
)
|
143
|
+
end
|
144
|
+
|
145
|
+
nil # Structure validation passed
|
141
146
|
end
|
142
147
|
|
143
|
-
|
144
|
-
|
148
|
+
# Validate that query contains only a single statement
|
149
|
+
def validate_single_statement(normalized_sql)
|
150
|
+
statements = normalized_sql.split(";").reject { |s| s.nil? || s.strip.empty? }
|
151
|
+
if statements.size > 1
|
152
|
+
return ValidationResult.new(
|
153
|
+
valid?: false,
|
154
|
+
error_message: "Multiple SQL statements are not allowed"
|
155
|
+
)
|
156
|
+
end
|
157
|
+
|
158
|
+
nil # Single statement validation passed
|
145
159
|
end
|
146
160
|
|
147
|
-
|
148
|
-
|
149
|
-
|
161
|
+
# Helper methods
|
162
|
+
def get_max_query_length
|
163
|
+
# Try to get from configuration if available, otherwise use default
|
164
|
+
if defined?(Dbviewer) && Dbviewer.respond_to?(:configuration) && Dbviewer.configuration.respond_to?(:max_query_length)
|
165
|
+
Dbviewer.configuration.max_query_length || ValidationConfig::DEFAULT_MAX_QUERY_LENGTH
|
166
|
+
else
|
167
|
+
ValidationConfig::DEFAULT_MAX_QUERY_LENGTH
|
150
168
|
end
|
151
169
|
end
|
152
170
|
|
153
|
-
|
154
|
-
|
171
|
+
def pragma_statement?(normalized_sql)
|
172
|
+
normalized_sql =~ /\A\s*PRAGMA\s+[a-z0-9_]+(\([^)]*\))?\s*\z/i
|
155
173
|
end
|
156
174
|
|
157
|
-
|
158
|
-
|
159
|
-
if statements.size > 1
|
160
|
-
raise SecurityError, "Multiple SQL statements are not allowed"
|
175
|
+
def valid_query_start?(normalized_sql)
|
176
|
+
normalized_sql =~ /\A\s*(SELECT|WITH)\s+/i
|
161
177
|
end
|
162
178
|
|
163
|
-
|
164
|
-
|
179
|
+
def detect_forbidden_keywords(normalized_sql)
|
180
|
+
ValidationConfig::FORBIDDEN_KEYWORDS.find do |keyword|
|
181
|
+
normalized_sql =~ /\b#{keyword}\b/i
|
182
|
+
end
|
165
183
|
end
|
166
184
|
|
167
|
-
normalized_sql
|
168
|
-
|
185
|
+
def detect_subqueries(normalized_sql)
|
186
|
+
# Check if there are unbalanced parentheses that likely contain subqueries
|
187
|
+
normalized_sql.count("(") > normalized_sql.count(")")
|
188
|
+
end
|
189
|
+
|
190
|
+
# Method missing handler for backward compatibility with legacy method calls
|
191
|
+
def method_missing(method_name, *args, **kwargs, &block)
|
192
|
+
case method_name
|
193
|
+
when :has_suspicious_patterns?
|
194
|
+
ThreatDetector.has_suspicious_patterns?(*args)
|
195
|
+
when :has_injection_patterns?
|
196
|
+
ThreatDetector.has_injection_patterns?(*args)
|
197
|
+
when :normalize
|
198
|
+
QueryNormalizer.normalize(*args)
|
199
|
+
else
|
200
|
+
super
|
201
|
+
end
|
202
|
+
end
|
169
203
|
|
170
|
-
|
171
|
-
|
172
|
-
# @param feature [Symbol] The feature to check for (:join, :subquery, :order_by, etc.)
|
173
|
-
# @return [Boolean] true if the feature is used in the query
|
174
|
-
def self.uses_feature?(sql, feature)
|
175
|
-
normalized = normalize(sql)
|
176
|
-
case feature
|
177
|
-
when :join
|
178
|
-
normalized =~ /\b(INNER|LEFT|RIGHT|FULL|CROSS)?\s*JOIN\b/i
|
179
|
-
when :subquery
|
180
|
-
# Check if there are parentheses that likely contain a subquery
|
181
|
-
normalized.count("(") > normalized.count(")")
|
182
|
-
when :order_by
|
183
|
-
normalized =~ /\bORDER\s+BY\b/i
|
184
|
-
when :group_by
|
185
|
-
normalized =~ /\bGROUP\s+BY\b/i
|
186
|
-
when :having
|
187
|
-
normalized =~ /\bHAVING\b/i
|
188
|
-
when :union
|
189
|
-
normalized =~ /\bUNION\b/i
|
190
|
-
when :window_function
|
191
|
-
normalized =~ /\bOVER\s*\(/i
|
192
|
-
else
|
193
|
-
false
|
204
|
+
def respond_to_missing?(method_name, include_private = false)
|
205
|
+
[ :has_suspicious_patterns?, :has_injection_patterns?, :normalize ].include?(method_name) || super
|
194
206
|
end
|
195
207
|
end
|
196
208
|
end
|
data/lib/dbviewer/version.rb
CHANGED
data/lib/dbviewer.rb
CHANGED
@@ -7,13 +7,15 @@ require "dbviewer/storage/base"
|
|
7
7
|
require "dbviewer/storage/in_memory_storage"
|
8
8
|
require "dbviewer/storage/file_storage"
|
9
9
|
|
10
|
+
require "dbviewer/cache/base"
|
11
|
+
require "dbviewer/cache/in_memory"
|
12
|
+
|
10
13
|
require "dbviewer/query/executor"
|
11
14
|
require "dbviewer/query/analyzer"
|
12
15
|
require "dbviewer/query/logger"
|
13
16
|
require "dbviewer/query/notification_subscriber"
|
14
17
|
require "dbviewer/query/parser"
|
15
18
|
|
16
|
-
require "dbviewer/database/cache_manager"
|
17
19
|
require "dbviewer/database/dynamic_model_factory"
|
18
20
|
require "dbviewer/database/manager"
|
19
21
|
require "dbviewer/database/metadata_manager"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dbviewer
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.7.
|
4
|
+
version: 0.7.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Wailan Tirajoh
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-06-
|
11
|
+
date: 2025-06-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -120,8 +120,9 @@ files:
|
|
120
120
|
- app/views/layouts/dbviewer/shared/_sidebar.html.erb
|
121
121
|
- config/routes.rb
|
122
122
|
- lib/dbviewer.rb
|
123
|
+
- lib/dbviewer/cache/base.rb
|
124
|
+
- lib/dbviewer/cache/in_memory.rb
|
123
125
|
- lib/dbviewer/configuration.rb
|
124
|
-
- lib/dbviewer/database/cache_manager.rb
|
125
126
|
- lib/dbviewer/database/dynamic_model_factory.rb
|
126
127
|
- lib/dbviewer/database/manager.rb
|
127
128
|
- lib/dbviewer/database/metadata_manager.rb
|
@@ -137,6 +138,10 @@ files:
|
|
137
138
|
- lib/dbviewer/storage/file_storage.rb
|
138
139
|
- lib/dbviewer/storage/in_memory_storage.rb
|
139
140
|
- lib/dbviewer/validator/sql.rb
|
141
|
+
- lib/dbviewer/validator/sql/query_normalizer.rb
|
142
|
+
- lib/dbviewer/validator/sql/threat_detector.rb
|
143
|
+
- lib/dbviewer/validator/sql/validation_config.rb
|
144
|
+
- lib/dbviewer/validator/sql/validation_result.rb
|
140
145
|
- lib/dbviewer/version.rb
|
141
146
|
- lib/generators/dbviewer/install_generator.rb
|
142
147
|
- lib/generators/dbviewer/templates/initializer.rb
|
@@ -1,78 +0,0 @@
|
|
1
|
-
module Dbviewer
|
2
|
-
module Database
|
3
|
-
# CacheManager handles caching concerns for the DatabaseManager
|
4
|
-
# It provides an abstraction layer for managing caches efficiently
|
5
|
-
class CacheManager
|
6
|
-
# Initialize the cache manager
|
7
|
-
# @param cache_expiry [Integer] Cache expiration time in seconds (default: 300)
|
8
|
-
def initialize(cache_expiry = 300)
|
9
|
-
@cache_expiry = cache_expiry
|
10
|
-
@dynamic_models = {}
|
11
|
-
@table_columns_cache = {}
|
12
|
-
@table_metadata_cache = {}
|
13
|
-
@cache_last_reset = Time.now
|
14
|
-
end
|
15
|
-
|
16
|
-
# Get a model from cache or return nil
|
17
|
-
# @param table_name [String] Name of the table
|
18
|
-
# @return [Class, nil] The cached model or nil if not found
|
19
|
-
def get_model(table_name)
|
20
|
-
@dynamic_models[table_name]
|
21
|
-
end
|
22
|
-
|
23
|
-
# Store a model in the cache
|
24
|
-
# @param table_name [String] Name of the table
|
25
|
-
# @param model [Class] ActiveRecord model class
|
26
|
-
def store_model(table_name, model)
|
27
|
-
@dynamic_models[table_name] = model
|
28
|
-
end
|
29
|
-
|
30
|
-
# Get column information from cache
|
31
|
-
# @param table_name [String] Name of the table
|
32
|
-
# @return [Array<Hash>, nil] The cached column information or nil if not found
|
33
|
-
def get_columns(table_name)
|
34
|
-
@table_columns_cache[table_name]
|
35
|
-
end
|
36
|
-
|
37
|
-
# Store column information in cache
|
38
|
-
# @param table_name [String] Name of the table
|
39
|
-
# @param columns [Array<Hash>] Column information
|
40
|
-
def store_columns(table_name, columns)
|
41
|
-
@table_columns_cache[table_name] = columns
|
42
|
-
end
|
43
|
-
|
44
|
-
# Get table metadata from cache
|
45
|
-
# @param table_name [String] Name of the table
|
46
|
-
# @return [Hash, nil] The cached metadata or nil if not found
|
47
|
-
def get_metadata(table_name)
|
48
|
-
@table_metadata_cache[table_name]
|
49
|
-
end
|
50
|
-
|
51
|
-
# Store table metadata in cache
|
52
|
-
# @param table_name [String] Name of the table
|
53
|
-
# @param metadata [Hash] Table metadata
|
54
|
-
def store_metadata(table_name, metadata)
|
55
|
-
@table_metadata_cache[table_name] = metadata
|
56
|
-
end
|
57
|
-
|
58
|
-
# Reset caches if they've been around too long
|
59
|
-
def reset_if_needed
|
60
|
-
if Time.now - @cache_last_reset > @cache_expiry
|
61
|
-
@table_columns_cache = {}
|
62
|
-
@table_metadata_cache = {}
|
63
|
-
@cache_last_reset = Time.now
|
64
|
-
Rails.logger.debug("[DBViewer] Cache reset due to expiry after #{@cache_expiry} seconds")
|
65
|
-
end
|
66
|
-
end
|
67
|
-
|
68
|
-
# Clear all caches - useful when schema changes are detected
|
69
|
-
def clear_all
|
70
|
-
@dynamic_models = {}
|
71
|
-
@table_columns_cache = {}
|
72
|
-
@table_metadata_cache = {}
|
73
|
-
@cache_last_reset = Time.now
|
74
|
-
Rails.logger.debug("[DBViewer] All caches cleared")
|
75
|
-
end
|
76
|
-
end
|
77
|
-
end
|
78
|
-
end
|