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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/DESIGN.md +420 -0
  4. data/INDEX.md +309 -0
  5. data/README.md +579 -30
  6. data/exe/queryguard +23 -0
  7. data/lib/query_guard/action_controller_subscriber.rb +27 -0
  8. data/lib/query_guard/analysis/query_risk_classifier.rb +124 -0
  9. data/lib/query_guard/analysis/risk_detectors.rb +258 -0
  10. data/lib/query_guard/analysis/risk_level.rb +35 -0
  11. data/lib/query_guard/analyzers/base.rb +30 -0
  12. data/lib/query_guard/analyzers/query_count_analyzer.rb +31 -0
  13. data/lib/query_guard/analyzers/query_risk_analyzer.rb +146 -0
  14. data/lib/query_guard/analyzers/registry.rb +57 -0
  15. data/lib/query_guard/analyzers/select_star_analyzer.rb +42 -0
  16. data/lib/query_guard/analyzers/slow_query_analyzer.rb +39 -0
  17. data/lib/query_guard/budget.rb +148 -0
  18. data/lib/query_guard/cli/batch_report_formatter.rb +129 -0
  19. data/lib/query_guard/cli/command.rb +93 -0
  20. data/lib/query_guard/cli/commands/analyze.rb +52 -0
  21. data/lib/query_guard/cli/commands/check.rb +58 -0
  22. data/lib/query_guard/cli/formatter.rb +278 -0
  23. data/lib/query_guard/cli/json_reporter.rb +247 -0
  24. data/lib/query_guard/cli/paged_report_formatter.rb +137 -0
  25. data/lib/query_guard/cli/source_metadata_collector.rb +297 -0
  26. data/lib/query_guard/cli.rb +197 -0
  27. data/lib/query_guard/client.rb +4 -6
  28. data/lib/query_guard/config.rb +145 -6
  29. data/lib/query_guard/core/context.rb +80 -0
  30. data/lib/query_guard/core/finding.rb +162 -0
  31. data/lib/query_guard/core/finding_builders.rb +152 -0
  32. data/lib/query_guard/core/query.rb +40 -0
  33. data/lib/query_guard/explain/adapter_interface.rb +89 -0
  34. data/lib/query_guard/explain/explain_enricher.rb +367 -0
  35. data/lib/query_guard/explain/plan_signals.rb +385 -0
  36. data/lib/query_guard/explain/postgresql_adapter.rb +208 -0
  37. data/lib/query_guard/exporter.rb +124 -0
  38. data/lib/query_guard/fingerprint.rb +96 -0
  39. data/lib/query_guard/middleware.rb +101 -15
  40. data/lib/query_guard/migrations/database_adapter.rb +88 -0
  41. data/lib/query_guard/migrations/migration_analyzer.rb +100 -0
  42. data/lib/query_guard/migrations/migration_risk_detectors.rb +287 -0
  43. data/lib/query_guard/migrations/postgresql_adapter.rb +157 -0
  44. data/lib/query_guard/migrations/table_risk_analyzer.rb +154 -0
  45. data/lib/query_guard/migrations/table_size_resolver.rb +152 -0
  46. data/lib/query_guard/publish.rb +38 -0
  47. data/lib/query_guard/rspec.rb +119 -0
  48. data/lib/query_guard/security.rb +99 -0
  49. data/lib/query_guard/store.rb +38 -0
  50. data/lib/query_guard/subscriber.rb +46 -15
  51. data/lib/query_guard/suggest/index_suggester.rb +176 -0
  52. data/lib/query_guard/suggest/pattern_extractors.rb +137 -0
  53. data/lib/query_guard/trace.rb +106 -0
  54. data/lib/query_guard/uploader/http_uploader.rb +166 -0
  55. data/lib/query_guard/uploader/interface.rb +79 -0
  56. data/lib/query_guard/uploader/no_op_uploader.rb +46 -0
  57. data/lib/query_guard/uploader/registry.rb +37 -0
  58. data/lib/query_guard/uploader/upload_service.rb +80 -0
  59. data/lib/query_guard/version.rb +1 -1
  60. data/lib/query_guard.rb +54 -7
  61. metadata +78 -10
  62. data/.rspec +0 -3
  63. data/Rakefile +0 -21
  64. data/config/initializers/query_guard.rb +0 -9
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "securerandom"
7
+ require "digest"
8
+
9
+ module QueryGuard
10
+ class Exporter
11
+ def initialize(config)
12
+ @config = config
13
+ end
14
+
15
+ def enabled?
16
+ @config.base_url && @config.api_key && @config.project
17
+ end
18
+
19
+ def export!(stats)
20
+ return unless enabled?
21
+
22
+ payload = build_payload(stats)
23
+ return if payload[:events].empty?
24
+
25
+ if @config.export_mode.to_sym == :async
26
+ Thread.new { post(payload) }
27
+ else
28
+ post(payload)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def build_payload(stats)
35
+ events = []
36
+
37
+ # Query events
38
+ if @config.export_queries.to_sym == :all
39
+ stats.fetch(:queries, []).each do |q|
40
+ events << {
41
+ type: "query",
42
+ statement: q[:sql],
43
+ durationMs: q[:duration_ms],
44
+ timestamp: q[:occurred_at],
45
+ originApp: @config.origin_app,
46
+ fingerprint: fingerprint(q[:sql]),
47
+ metadata: stats[:request] || {}
48
+ }
49
+ end
50
+ end
51
+
52
+ # Threat events from violations
53
+ stats.fetch(:violations, []).each do |v|
54
+ events << violation_to_threat(v, stats)
55
+ end
56
+
57
+ {
58
+ projectId: @config.project,
59
+ events: events.compact
60
+ }
61
+ end
62
+
63
+ def fingerprint(sql)
64
+ # cheap “normalized” hash
65
+ normalized = sql.to_s.gsub(/\s+/, " ").strip
66
+ Digest::SHA1.hexdigest(normalized)
67
+ end
68
+
69
+ def violation_to_threat(v, stats)
70
+ case v[:type]&.to_sym
71
+ when :slow_query
72
+ {
73
+ type: "threat",
74
+ severity: "medium",
75
+ threatType: "SlowQuery",
76
+ description: "Slow query #{v[:duration_ms]}ms",
77
+ timestamp: Time.now.utc.iso8601,
78
+ originApp: @config.origin_app,
79
+ metadata: {
80
+ sql: v[:sql],
81
+ request: stats[:request] || {}
82
+ }
83
+ }
84
+ when :select_star
85
+ {
86
+ type: "threat",
87
+ severity: "low",
88
+ threatType: "SelectStar",
89
+ description: "SELECT * detected",
90
+ timestamp: Time.now.utc.iso8601,
91
+ originApp: @config.origin_app,
92
+ metadata: {
93
+ sql: v[:sql],
94
+ request: stats[:request] || {}
95
+ }
96
+ }
97
+ when :too_many_queries
98
+ {
99
+ type: "threat",
100
+ severity: "high",
101
+ threatType: "TooManyQueries",
102
+ description: "Query count #{v[:count]} exceeded limit #{v[:limit]}",
103
+ timestamp: Time.now.utc.iso8601,
104
+ originApp: @config.origin_app,
105
+ metadata: { request: stats[:request] || {} }
106
+ }
107
+ end
108
+ end
109
+
110
+ def post(payload)
111
+ uri = URI.join(@config.base_url.to_s, "/api/v1/events")
112
+ http = Net::HTTP.new(uri.host, uri.port)
113
+ http.use_ssl = (uri.scheme == "https")
114
+ req = Net::HTTP::Post.new(uri.request_uri)
115
+ req["Content-Type"] = "application/json"
116
+ req["Authorization"] = "Bearer #{@config.api_key}"
117
+ req.body = JSON.generate(payload)
118
+ http.request(req)
119
+ rescue => e
120
+ # Don’t crash the app if monitoring fails
121
+ warn "#{@config.log_prefix} export failed: #{e.class}: #{e.message}"
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+ require "digest"
3
+
4
+ module QueryGuard
5
+ # SQL fingerprinting and statistics module.
6
+ # Normalizes SQL queries and tracks per-fingerprint statistics.
7
+ module Fingerprint
8
+ class << self
9
+ # Generate a stable fingerprint for a SQL query by:
10
+ # - Normalizing whitespace
11
+ # - Replacing string literals with ?
12
+ # - Replacing numeric literals with ?
13
+ # - Replacing IN (...) lists with IN (?)
14
+ # Returns a SHA1 hash of the normalized query
15
+ def generate(sql)
16
+ normalized = normalize(sql)
17
+ Digest::SHA1.hexdigest(normalized)
18
+ end
19
+
20
+ # Normalize SQL by removing all literals and standardizing whitespace
21
+ def normalize(sql)
22
+ s = sql.to_s.dup
23
+
24
+ # Replace string literals (both single and double quotes)
25
+ s.gsub!(/'(?:''|[^'])*'/, "?")
26
+ s.gsub!(/"(?:""|[^"])*"/, "?")
27
+
28
+ # Replace numeric literals (integers and floats)
29
+ s.gsub!(/\b\d+\.?\d*\b/, "?")
30
+
31
+ # Replace IN (...) with IN (?)
32
+ s.gsub!(/IN\s*\([^)]+\)/i, "IN (?)")
33
+
34
+ # Normalize whitespace
35
+ s.gsub!(/\s+/, " ")
36
+
37
+ s.strip.downcase
38
+ end
39
+
40
+ # Get or initialize the stats store for the current process
41
+ def stats
42
+ @stats ||= Hash.new do |h, fp|
43
+ h[fp] = {
44
+ count: 0,
45
+ total_duration_ms: 0.0,
46
+ min_duration_ms: Float::INFINITY,
47
+ max_duration_ms: 0.0,
48
+ first_seen_at: Time.now,
49
+ last_seen_at: Time.now
50
+ }
51
+ end
52
+ end
53
+
54
+ # Record a query execution
55
+ def record(sql, duration_ms)
56
+ fp = generate(sql)
57
+ stat = stats[fp]
58
+
59
+ stat[:count] += 1
60
+ stat[:total_duration_ms] += duration_ms
61
+ stat[:min_duration_ms] = [stat[:min_duration_ms], duration_ms].min
62
+ stat[:max_duration_ms] = [stat[:max_duration_ms], duration_ms].max
63
+ stat[:last_seen_at] = Time.now
64
+
65
+ fp
66
+ end
67
+
68
+ # Get stats for a specific fingerprint
69
+ def stats_for(fingerprint)
70
+ stats[fingerprint]
71
+ end
72
+
73
+ # Get all fingerprints sorted by frequency
74
+ def top_by_count(limit = 10)
75
+ stats.sort_by { |_fp, s| -s[:count] }.first(limit).to_h
76
+ end
77
+
78
+ # Get all fingerprints sorted by total duration
79
+ def top_by_duration(limit = 10)
80
+ stats.sort_by { |_fp, s| -s[:total_duration_ms] }.first(limit).to_h
81
+ end
82
+
83
+ # Get all fingerprints sorted by average duration
84
+ def top_by_avg_duration(limit = 10)
85
+ stats.sort_by { |_fp, s|
86
+ s[:count] > 0 ? -(s[:total_duration_ms] / s[:count]) : 0
87
+ }.first(limit).to_h
88
+ end
89
+
90
+ # Reset all stats (useful for testing)
91
+ def reset!
92
+ @stats = nil
93
+ end
94
+ end
95
+ end
96
+ end
@@ -1,24 +1,63 @@
1
1
  # frozen_string_literal: true
2
+ require "securerandom"
3
+ require "query_guard/security"
4
+
2
5
  module QueryGuard
3
6
  class Error < StandardError; end
4
7
 
5
8
  class Middleware
9
+ class BodyProxy
10
+ def initialize(body, stats)
11
+ @body = body
12
+ @stats = stats
13
+ end
14
+
15
+ def each
16
+ @body.each do |chunk|
17
+ @stats[:response_bytes] += chunk.to_s.bytesize
18
+ yield chunk
19
+ end
20
+ end
21
+
22
+ def close
23
+ @body.close if @body.respond_to?(:close)
24
+ end
25
+ end
26
+
6
27
  def initialize(app, config)
7
28
  @app = app
8
29
  @config = config
9
30
  end
10
31
 
11
32
  def call(env)
12
- unless @config.enabled?(rails_env)
13
- return @app.call(env)
14
- end
33
+ return @app.call(env) unless @config.enabled?(rails_env)
15
34
 
16
- Thread.current[:query_guard_stats] = { count: 0, total_duration_ms: 0.0, violations: [] }
35
+ # Initialize new Context for this request
36
+ context = Core::Context.new
37
+ Thread.current[:query_guard_context] = context
38
+
39
+ # Legacy: Also initialize old stats hash for backward compatibility
40
+ Thread.current[:query_guard_stats] = {
41
+ count: 0,
42
+ total_duration_ms: 0.0,
43
+ violations: [],
44
+ response_bytes: 0
45
+ }
17
46
 
18
47
  status, headers, body = @app.call(env)
19
- check_and_report!
20
- [status, headers, body]
48
+
49
+ # Wrap body to track response bytes
50
+ proxied_body = BodyProxy.new(body, Thread.current[:query_guard_stats])
51
+
52
+ # Run all registered analyzers
53
+ findings = @config.analyzer_registry.analyze(context, @config)
54
+
55
+ # Check and report findings
56
+ check_and_report!(context, findings)
57
+
58
+ [status, headers, proxied_body]
21
59
  ensure
60
+ Thread.current[:query_guard_context] = nil
22
61
  Thread.current[:query_guard_stats] = nil
23
62
  end
24
63
 
@@ -40,20 +79,48 @@ module QueryGuard
40
79
  end
41
80
  end
42
81
 
43
- def check_and_report!
82
+ def check_and_report!(context, findings)
83
+ # Check findings and legacy stats for backward compatibility
44
84
  stats = Thread.current[:query_guard_stats] || { count: 0, violations: [] }
45
- violations = stats[:violations].dup
85
+ legacy_violations = stats[:violations].dup
46
86
 
47
- if @config.max_queries_per_request && stats[:count] > @config.max_queries_per_request
48
- violations << { type: :too_many_queries, count: stats[:count], limit: @config.max_queries_per_request }
87
+ return if findings.empty? && legacy_violations.empty?
88
+
89
+ # Log all findings
90
+ findings.each do |finding|
91
+ message = format_finding_message(finding)
92
+ logger.warn(message)
93
+ end
94
+
95
+ # Log legacy violations for compatibility
96
+ legacy_violations.each do |v|
97
+ message = format_legacy_message(v, stats)
98
+ logger.warn(message)
49
99
  end
50
100
 
51
- return if violations.empty?
101
+ # Raise if configured
102
+ if @config.raise_on_violation && (!findings.empty? || !legacy_violations.empty?)
103
+ message = format_summary_message(findings, stats)
104
+ raise QueryGuard::Error, message
105
+ end
106
+ end
52
107
 
53
- message = format_message(stats, violations)
54
- logger.warn(message)
108
+ def format_finding_message(finding)
109
+ "#{@config.log_prefix} [#{finding.severity.upcase}] #{finding.analyzer_name}:#{finding.rule_name} - #{finding.message}"
110
+ end
55
111
 
56
- raise QueryGuard::Error, message if @config.raise_on_violation
112
+ def format_legacy_message(v, stats)
113
+ details = case v[:type]
114
+ when :too_many_queries
115
+ "too_many_queries: count=#{v[:count]} limit=#{v[:limit]}"
116
+ when :slow_query
117
+ "slow_query: #{v[:duration_ms]}ms SQL=#{truncate_sql(v[:sql])}"
118
+ when :select_star
119
+ "select_star: SQL=#{truncate_sql(v[:sql])}"
120
+ else
121
+ v[:type].to_s
122
+ end
123
+ "#{@config.log_prefix} queries=#{stats[:count]} total_ms=#{stats[:total_duration_ms].round(2)} | #{details}"
57
124
  end
58
125
 
59
126
  def format_message(stats, violations)
@@ -65,12 +132,31 @@ module QueryGuard
65
132
  "slow_query: #{v[:duration_ms]}ms SQL=#{truncate_sql(v[:sql])}"
66
133
  when :select_star
67
134
  "select_star: SQL=#{truncate_sql(v[:sql])}"
135
+ when :sql_injection_suspected
136
+ "sql_injection_suspected: SQL=#{truncate_sql(v[:sql])}"
137
+ when :possible_data_exfiltration_query
138
+ "possible_exfiltration_query: SQL=#{truncate_sql(v[:sql])}"
139
+ when :data_exfiltration_large_response
140
+ "data_exfiltration_large_response: bytes=#{v[:bytes]} limit=#{v[:limit]} path=#{v[:path]}"
141
+ when :data_exfiltration_suspected_export
142
+ "data_exfiltration_suspected_export: bytes=#{v[:bytes]} path=#{v[:path]}"
143
+ when :unusual_query_rate
144
+ "unusual_query_rate: actor=#{v[:actor]} per_min=#{v[:per_minute]} limit=#{v[:limit]}"
145
+ when :unusual_query_variety
146
+ "unusual_query_variety: actor=#{v[:actor]} uniq_fp_per_min=#{v[:unique_fingerprints_per_minute]} limit=#{v[:limit]}"
147
+ when :mass_assignment_unpermitted_params
148
+ "mass_assignment_unpermitted: keys=#{v[:keys].join(',')} sensitive=#{v[:sensitive_keys].join(',')}"
68
149
  else
69
150
  v[:type].to_s
70
151
  end
71
152
  end.join(" | ")
72
153
 
73
- "#{@config.log_prefix} queries=#{stats[:count]} total_ms=#{stats[:total_duration_ms].round(2)} | #{details}"
154
+ "#{@config.log_prefix} queries=#{stats[:count]} total_ms=#{stats[:total_duration_ms].round(2)} resp_bytes=#{stats[:response_bytes]} | #{details}"
155
+ end
156
+
157
+ def format_summary_message(findings, stats)
158
+ details = findings.map { |f| "#{f.analyzer_name}:#{f.rule_name}" }.join(", ")
159
+ "#{@config.log_prefix} violations: #{details}"
74
160
  end
75
161
 
76
162
  def truncate_sql(sql, max = 200)
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ module Migrations
5
+ # Abstract interface for database metadata adapters.
6
+ #
7
+ # Adapters implement this interface to provide table metadata
8
+ # (row counts, lock behavior, etc.) from different database systems.
9
+ #
10
+ # Example:
11
+ # adapter = PostgreSQLAdapter.new(connection)
12
+ # rows = adapter.estimate_table_rows(:users)
13
+ #
14
+ # Adapters should:
15
+ # 1. Handle connection failures gracefully
16
+ # 2. Return nil or empty hash if data unavailable
17
+ # 3. Cache results where possible
18
+ # 4. Never raise exceptions during metadata queries
19
+ class DatabaseAdapter
20
+ # Get estimated row count for a table
21
+ #
22
+ # @param table_name [String, Symbol] Name of the table
23
+ # @return [Integer, nil] Estimated number of rows, or nil if unavailable
24
+ def estimate_table_rows(table_name)
25
+ raise NotImplementedError, "Subclasses must implement estimate_table_rows"
26
+ end
27
+
28
+ # Get lock impact estimate for a table
29
+ #
30
+ # Scores table's lock risk based on size and activity.
31
+ # Based on PostgreSQL's vacuum and maintenance overhead.
32
+ #
33
+ # @param table_name [String, Symbol] Name of the table
34
+ # @return [Symbol, nil] Risk level: :low, :medium, :high, :critical
35
+ def estimate_lock_risk(table_name)
36
+ raise NotImplementedError, "Subclasses must implement estimate_lock_risk"
37
+ end
38
+
39
+ # Check if table exists in database
40
+ #
41
+ # @param table_name [String, Symbol] Name of the table
42
+ # @return [Boolean] True if table exists
43
+ def table_exists?(table_name)
44
+ raise NotImplementedError, "Subclasses must implement table_exists?"
45
+ end
46
+
47
+ # Get all table names in current schema
48
+ #
49
+ # @return [Array<String>] List of table names
50
+ def list_tables
51
+ raise NotImplementedError, "Subclasses must implement list_tables"
52
+ end
53
+
54
+ # Check if connection is healthy
55
+ #
56
+ # @return [Boolean] True if adapter can query database
57
+ def connected?
58
+ raise NotImplementedError, "Subclasses must implement connected?"
59
+ end
60
+ end
61
+
62
+ # NoOp adapter that always returns unavailable
63
+ #
64
+ # Used when no database connection is configured.
65
+ # Gracefully disables database-aware analysis.
66
+ class NullDatabaseAdapter < DatabaseAdapter
67
+ def estimate_table_rows(table_name)
68
+ nil
69
+ end
70
+
71
+ def estimate_lock_risk(table_name)
72
+ nil
73
+ end
74
+
75
+ def table_exists?(table_name)
76
+ false
77
+ end
78
+
79
+ def list_tables
80
+ []
81
+ end
82
+
83
+ def connected?
84
+ false
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryGuard
4
+ module Migrations
5
+ # Migration risk analyzer - scans Rails migration files for risky patterns.
6
+ # Searches for dangerous operations like:
7
+ # - Index additions without algorithm: :concurrently
8
+ # - Column removals/type changes
9
+ # - Non-NULL additions without defaults
10
+ # - Full-table updates
11
+ # - Unsafe raw SQL
12
+ #
13
+ # Optionally integrates with database metadata for table-size-aware risk estimation.
14
+ #
15
+ # Example:
16
+ # analyzer = MigrationAnalyzer.new
17
+ # findings = analyzer.analyze_migration(migration_file_path)
18
+ #
19
+ # With table size awareness:
20
+ # connection = ActiveRecord::Base.connection
21
+ # adapter = PostgreSQLAdapter.new(connection)
22
+ # analyzer = MigrationAnalyzer.new(database_adapter: adapter)
23
+ # findings = analyzer.analyze_migration(migration_file_path)
24
+ class MigrationAnalyzer < Analyzers::Base
25
+ # Initialize analyzer
26
+ #
27
+ # @param database_adapter [DatabaseAdapter] Optional adapter for table metadata
28
+ def initialize(database_adapter: nil)
29
+ super(:migration_risk)
30
+ @database_adapter = database_adapter
31
+ @table_risk_analyzer = TableRiskAnalyzer.new(database_adapter)
32
+ end
33
+
34
+ # Analyze a single migration file
35
+ #
36
+ # @param file_path [String] Path to migration file
37
+ # @return [Array<Core::Finding>] List of findings
38
+ def analyze_migration(file_path)
39
+ return [] unless File.exist?(file_path)
40
+
41
+ content = File.read(file_path)
42
+ migration_name = File.basename(file_path, ".rb")
43
+
44
+ risks = MigrationRiskDetectors.detect_risks(content, migration_name)
45
+
46
+ # Enhance risks with table metadata if adapter available
47
+ risks = @table_risk_analyzer.enhance_risks(content, risks)
48
+
49
+ risks_to_findings(risks, file_path)
50
+ end
51
+
52
+ # Analyze all migrations in a directory
53
+ #
54
+ # @param migrations_dir [String] Path to db/migrate directory
55
+ # @return [Array<Core::Finding>] List of findings from all migrations
56
+ def analyze_migrations_directory(migrations_dir = "db/migrate")
57
+ findings = []
58
+ return findings unless Dir.exist?(migrations_dir)
59
+
60
+ Dir.glob(File.join(migrations_dir, "*.rb")).each do |file_path|
61
+ findings.concat(analyze_migration(file_path))
62
+ end
63
+
64
+ findings
65
+ end
66
+
67
+ # Analyze method for integration with QueryGuard context
68
+ # This is called by registry when running full analysis
69
+ #
70
+ # @param context [Core::Context] Not used for migrations, but required by Base
71
+ # @param config [Config] Configuration object
72
+ # @return [Array<Core::Finding>] List of findings
73
+ def analyze(context, config)
74
+ migrations_dir = config&.migrations_directory || "db/migrate"
75
+ analyze_migrations_directory(migrations_dir)
76
+ end
77
+
78
+ private
79
+
80
+ def risks_to_findings(risks, file_path)
81
+ risks.map do |risk|
82
+ severity = risk[:severity] || :warn
83
+
84
+ Core::FindingBuilders.build(
85
+ analyzer_name: name,
86
+ rule_name: risk[:type],
87
+ severity: severity,
88
+ title: risk[:title],
89
+ description: risk[:description],
90
+ message: risk[:message],
91
+ file_path: file_path,
92
+ line_number: risk[:line_number],
93
+ recommendations: [risk[:recommendation]],
94
+ metadata: risk[:metadata]
95
+ )
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end