secure-keys 1.1.7 → 1.2.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.
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module SecureKeys
4
+ module Validation
5
+ # Aggregates all findings from a single scan run
6
+ class ScanResult
7
+ attr_reader :findings, :files_count
8
+
9
+ # Initialize a new scan result
10
+ # @param findings [Array<Finding>] The list of detected secrets
11
+ # @param files_count [Integer] The total number of files (or diff lines) scanned
12
+ def initialize(findings:, files_count:)
13
+ @findings = findings
14
+ @files_count = files_count
15
+ end
16
+
17
+ # Check if the scan produced no findings
18
+ # @return [Boolean] true if no secrets were detected
19
+ def clean?
20
+ findings.empty?
21
+ end
22
+
23
+ # Returns findings filtered by a specific severity level
24
+ # @param severity [Symbol] The severity to filter by (:low, :medium, :high, :critical)
25
+ # @return [Array<Finding>] Findings matching the given severity
26
+ def by_severity(severity:)
27
+ findings.select { |finding| finding.severity == severity }
28
+ end
29
+
30
+ # Returns a hash representation of the scan result
31
+ # @return [Hash] The hash representation, suitable for JSON export
32
+ def to_h
33
+ {
34
+ files_scanned: files_count,
35
+ total_findings: findings.length,
36
+ by_severity: {
37
+ critical: by_severity(severity: :critical).length,
38
+ high: by_severity(severity: :high).length,
39
+ medium: by_severity(severity: :medium).length,
40
+ low: by_severity(severity: :low).length,
41
+ },
42
+ findings: findings.map(&:to_h),
43
+ }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../core/console/logger'
4
+ require_relative '../core/console/shell'
5
+ require_relative 'globals/globals'
6
+ require_relative 'utils/patterns'
7
+ require_relative 'models/finding'
8
+ require_relative 'models/scan_result'
9
+
10
+ module SecureKeys
11
+ module Validation
12
+ # Scans files and git diffs for exposed secrets using the known PATTERNS set
13
+ class Scanner
14
+ private
15
+
16
+ attr_accessor :findings, :options
17
+
18
+ public
19
+
20
+ # Initialize a new scanner
21
+ # @param options [Hash] Override specific default scanning options
22
+ # @option options [Array<String>] :extensions File extensions to include in the scan
23
+ # @option options [Array<String>] :excludes Directory and file names to exclude
24
+ # @option options [Integer] :max_depth Maximum directory traversal depth
25
+ # @option options [Boolean] :follow_symlinks Whether to follow symbolic links
26
+ def initialize(options: {})
27
+ self.options = default_options.merge(options)
28
+ self.findings = []
29
+ end
30
+
31
+ # Scan a directory recursively for exposed secrets
32
+ # @param path [String] The root directory path to scan (default: current directory)
33
+ # @param options [Hash] Additional options to merge for this scan only
34
+ # @return [ScanResult] The aggregated scan result
35
+ def scan_directory(path: '.', options: {})
36
+ self.findings = []
37
+ self.options = self.options.merge(options)
38
+
39
+ Core::Console::Logger.verbose(message: "Scanning directory: #{path}")
40
+ Core::Console::Logger.verbose(message: "Extensions: #{file_extensions.join(', ')}")
41
+ Core::Console::Logger.verbose(message: "Excludes: #{exclude_patterns.join(', ')}")
42
+
43
+ files = find_files(path:)
44
+
45
+ Core::Console::Logger.verbose(message: "Found #{files.length} files to scan")
46
+
47
+ files.each { |file| scan_file(file_path: file) }
48
+
49
+ ScanResult.new(findings:, files_count: files.length)
50
+ end
51
+
52
+ # Scan staged or unstaged git changes for exposed secrets
53
+ # @param staged_only [Boolean] When true, scans only staged changes (default: true)
54
+ # @return [ScanResult] The aggregated scan result
55
+ def scan_git_diff(staged_only: true)
56
+ self.findings = []
57
+
58
+ command = staged_only ? 'git diff --cached' : 'git diff'
59
+ diff_output, = Core::Console::Shell.sh(command:)
60
+
61
+ return ScanResult.new(findings: [], files_count: 0) if diff_output.strip.empty?
62
+
63
+ current_file = nil
64
+ line_number = 0
65
+
66
+ diff_output.each_line do |line|
67
+ if line.start_with?('+++')
68
+ current_file = line.sub(%r{^\+\+\+ b/}, '').strip
69
+ line_number = 0
70
+ elsif line.start_with?('@@') && current_file
71
+ hunk_match = line.match(/\+(\d+)/)
72
+ line_number = hunk_match ? hunk_match[1].to_i - 1 : 0
73
+ elsif line.start_with?('+') && current_file && !line.start_with?('+++')
74
+ line_number += 1
75
+ check_line(
76
+ file_path: current_file,
77
+ line_number:,
78
+ line: line[1..],
79
+ is_addition: true
80
+ )
81
+ end
82
+ end
83
+
84
+ ScanResult.new(findings:, files_count: diff_output.lines.count)
85
+ end
86
+
87
+ private
88
+
89
+ # Scan a single file line by line for exposed secrets
90
+ # @param file_path [String] The path to the file to scan
91
+ def scan_file(file_path:)
92
+ return unless File.file?(file_path)
93
+
94
+ Core::Console::Logger.verbose(message: "Scanning #{file_path}...")
95
+
96
+ content = File.read(file_path)
97
+ line_number = 0
98
+
99
+ content.each_line do |line|
100
+ line_number += 1
101
+ check_line(file_path:, line_number:, line:)
102
+ end
103
+ rescue StandardError => e
104
+ Core::Console::Logger.verbose(message: "Failed to scan #{file_path}: #{e.message}")
105
+ end
106
+
107
+ # Check a single line against all known secret patterns and suspicious assignments
108
+ # @param file_path [String] The path of the file being scanned
109
+ # @param line_number [Integer] The current line number within the file
110
+ # @param line [String] The content of the line to check
111
+ # @param is_addition [Boolean] Whether the line is a git diff addition (default: false)
112
+ def check_line(file_path:, line_number:, line:, is_addition: false)
113
+ return if line.strip.start_with?('#', '//', '/*', '*')
114
+ return if line.length < 10
115
+
116
+ seen = {}
117
+
118
+ PATTERNS.each do |type, config|
119
+ match = line.match(config[:pattern])
120
+ next unless match
121
+
122
+ signature = [match.begin(0), match[0]]
123
+ next if seen[signature]
124
+
125
+ seen[signature] = true
126
+ masked = mask_secret(secret: match[0])
127
+
128
+ findings << Finding.new(
129
+ file: file_path,
130
+ line: line_number,
131
+ column: match.begin(0),
132
+ type:,
133
+ description: config[:description],
134
+ severity: config[:severity],
135
+ matched_text: masked,
136
+ full_line: line.strip.sub(match[0], masked),
137
+ is_addition:
138
+ )
139
+ end
140
+
141
+ check_suspicious_assignments(file_path:, line_number:, line:, is_addition:)
142
+ end
143
+
144
+ # Check a line for generic suspicious variable assignment patterns not caught by PATTERNS
145
+ # @param file_path [String] The path of the file being scanned
146
+ # @param line_number [Integer] The current line number within the file
147
+ # @param line [String] The content of the line
148
+ # @param is_addition [Boolean] Whether the line is a git diff addition
149
+ def check_suspicious_assignments(file_path:, line_number:, line:, is_addition:)
150
+ suspicious_pattern = /(?i)(api_?key|secret|token|password|passwd|pwd|auth|credential)\s*[=:]\s*['"]([^'"]{8,})['"]/
151
+
152
+ return unless line.match?(suspicious_pattern)
153
+ return if already_matched_by_pattern?(line:)
154
+
155
+ match = line.match(suspicious_pattern)
156
+ masked = mask_secret(secret: match[0])
157
+
158
+ findings << Finding.new(
159
+ file: file_path,
160
+ line: line_number,
161
+ column: match.begin(0),
162
+ type: :suspicious_assignment,
163
+ description: 'Suspicious secret assignment',
164
+ severity: :low,
165
+ matched_text: masked,
166
+ full_line: line.strip.sub(match[0], masked),
167
+ is_addition:
168
+ )
169
+ end
170
+
171
+ # Check whether a line was already captured by one of the specific PATTERNS
172
+ # @param line [String] The line content to check
173
+ # @return [Boolean] true if the line matches any known pattern
174
+ def already_matched_by_pattern?(line:)
175
+ PATTERNS.values.any? { |config| line.match?(config[:pattern]) }
176
+ end
177
+
178
+ # Recursively find all scannable files under a root path
179
+ # @param path [String] The root directory path
180
+ # @return [Array<String>] The list of matching absolute file paths
181
+ def find_files(path:)
182
+ result = []
183
+ traverse_directory(
184
+ path:,
185
+ result:,
186
+ current_depth: 0,
187
+ max_depth: options.fetch(:max_depth, Globals.max_scan_depth)
188
+ )
189
+ result
190
+ end
191
+
192
+ # Recursively traverse a directory, collecting files that match the scan criteria
193
+ # @param path [String] The current directory path
194
+ # @param result [Array<String>] The accumulator for matching file paths
195
+ # @param current_depth [Integer] The current traversal depth
196
+ # @param max_depth [Integer] The maximum allowed traversal depth
197
+ def traverse_directory(path:, result:, current_depth:, max_depth:)
198
+ return if current_depth > max_depth
199
+
200
+ Dir.each_child(path) do |entry|
201
+ next if excluded?(name: entry)
202
+
203
+ full_path = File.join(path, entry)
204
+
205
+ next if File.symlink?(full_path) && !options.fetch(:follow_symlinks, false)
206
+
207
+ if File.directory?(full_path)
208
+ traverse_directory(
209
+ path: full_path,
210
+ result:,
211
+ current_depth: current_depth + 1,
212
+ max_depth:
213
+ )
214
+ elsif File.file?(full_path) && included_extension?(path: full_path)
215
+ result << full_path
216
+ end
217
+ end
218
+ rescue Errno::EACCES, Errno::ENOENT => e
219
+ Core::Console::Logger.verbose(message: "Skipping #{path}: #{e.message}")
220
+ end
221
+
222
+ # Check whether a file or directory name matches an exclude pattern
223
+ # @param name [String] The file or directory name to check
224
+ # @return [Boolean] true if the entry should be excluded
225
+ def excluded?(name:)
226
+ exclude_patterns.any? { |pattern| name == pattern }
227
+ end
228
+
229
+ # Check whether a file path has an extension that should be scanned
230
+ # @param path [String] The file path to check
231
+ # @return [Boolean] true if the file extension is in the inclusion list
232
+ def included_extension?(path:)
233
+ file_extensions.include?(File.extname(path))
234
+ end
235
+
236
+ # Returns the configured list of file extensions to scan
237
+ # @return [Array<String>] The file extensions
238
+ def file_extensions
239
+ options.fetch(:extensions, Globals.default_scan_extensions)
240
+ end
241
+
242
+ # Returns the configured list of directory and file names to exclude
243
+ # @return [Array<String>] The exclude patterns
244
+ def exclude_patterns
245
+ options.fetch(:excludes, Globals.default_scan_excludes)
246
+ end
247
+
248
+ # Mask a secret value so only the first four characters are visible
249
+ # @param secret [String] The secret string to mask
250
+ # @return [String] The masked string, safe for display in logs
251
+ def mask_secret(secret:)
252
+ return '***' if secret.length <= 6
253
+
254
+ "#{secret[0..3]}#{'*' * (secret.length - 4)}"
255
+ end
256
+
257
+ # Builds the default scanning options from globals
258
+ # @return [Hash] The default options hash
259
+ def default_options
260
+ {
261
+ extensions: Globals.default_scan_extensions,
262
+ excludes: Globals.default_scan_excludes,
263
+ max_depth: Globals.max_scan_depth,
264
+ follow_symlinks: false,
265
+ }
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module SecureKeys
4
+ module Validation
5
+ module Entropy
6
+ module_function
7
+
8
+ # Calculate the Shannon entropy of a string
9
+ # @param string [String] The string to analyze
10
+ # @return [Float] The Shannon entropy value (higher means more random)
11
+ def calculate(string:)
12
+ return 0.0 if string.empty?
13
+
14
+ frequencies = Hash.new(0)
15
+ string.each_char { |char| frequencies[char] += 1 }
16
+
17
+ frequencies.each_value.sum do |count|
18
+ frequency = count.to_f / string.length
19
+ -frequency * Math.log2(frequency)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../globals/globals'
4
+
5
+ module SecureKeys
6
+ module Validation
7
+ # Minimum length requirements by key type
8
+ MIN_LENGTHS = {
9
+ api_key: Globals.api_key_length,
10
+ token: Globals.token_length,
11
+ secret: Globals.secret_length,
12
+ password: Globals.password_length,
13
+ key: Globals.key_length,
14
+ }.freeze
15
+ end
16
+ end
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # rubocop:disable Lint/Syntax, Metrics/ModuleLength
4
+
5
+ module SecureKeys
6
+ module Validation
7
+ # Known secret patterns with descriptions
8
+ PATTERNS = {
9
+ # GitHub
10
+ github_token: {
11
+ pattern: /ghp_[a-zA-Z0-9]{36}/,
12
+ description: 'GitHub Personal Access Token',
13
+ severity: :high,
14
+ example: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
15
+ },
16
+ github_oauth: {
17
+ pattern: /gho_[a-zA-Z0-9]{36}/,
18
+ description: 'GitHub OAuth Access Token',
19
+ severity: :high,
20
+ example: 'gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
21
+ },
22
+ github_app: {
23
+ pattern: /(ghu|ghs)_[a-zA-Z0-9]{36}/,
24
+ description: 'GitHub App Token',
25
+ severity: :high,
26
+ example: 'ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
27
+ },
28
+ github_refresh: {
29
+ pattern: /ghr_[a-zA-Z0-9]{36}/,
30
+ description: 'GitHub Refresh Token',
31
+ severity: :high,
32
+ example: 'ghr_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
33
+ },
34
+
35
+ # AWS
36
+ aws_access_key: {
37
+ pattern: /AKIA[0-9A-Z]{16}/,
38
+ description: 'AWS Access Key ID',
39
+ severity: :critical,
40
+ example: 'AKIAIOSFODNN7EXAMPLE'
41
+ },
42
+ aws_secret_key: {
43
+ pattern: %r{(?i)aws(.{0,20})?['\"][0-9a-zA-Z/+]{40}['\"]},
44
+ description: 'AWS Secret Access Key',
45
+ severity: :critical,
46
+ example: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'
47
+ },
48
+ aws_session_token: {
49
+ pattern: %r{(?i)aws(.{0,20})?session(.{0,20})?['\"][0-9a-zA-Z/+]{100,}['\"]},
50
+ description: 'AWS Session Token',
51
+ severity: :high,
52
+ example: 'FwoGZXIvYXdzEBaa...(very long token)'
53
+ },
54
+
55
+ # Google Cloud
56
+ gcp_api_key: {
57
+ pattern: /AIza[0-9A-Za-z\-_]{35}/,
58
+ description: 'Google Cloud API Key',
59
+ severity: :high,
60
+ example: 'AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe'
61
+ },
62
+ gcp_oauth: {
63
+ pattern: /ya29\.[0-9A-Za-z\-_]+/,
64
+ description: 'Google OAuth Access Token',
65
+ severity: :high,
66
+ example: 'ya29.a0AfH6SMBx...'
67
+ },
68
+
69
+ # Stripe
70
+ stripe_secret_key: {
71
+ pattern: /sk_(live|test)_[0-9a-zA-Z]{24,}/,
72
+ description: 'Stripe Secret Key',
73
+ severity: :critical,
74
+ example: 'sk_live_51H...'
75
+ },
76
+ stripe_publishable_key: {
77
+ pattern: /pk_(live|test)_[0-9a-zA-Z]{24,}/,
78
+ description: 'Stripe Publishable Key',
79
+ severity: :medium,
80
+ example: 'pk_live_51H...'
81
+ },
82
+ stripe_restricted_key: {
83
+ pattern: /rk_(live|test)_[0-9a-zA-Z]{24,}/,
84
+ description: 'Stripe Restricted Key',
85
+ severity: :high,
86
+ example: 'rk_live_51H...'
87
+ },
88
+
89
+ # Slack
90
+ slack_token: {
91
+ pattern: /xox[baprs]-[0-9a-zA-Z]{10,48}/,
92
+ description: 'Slack Token',
93
+ severity: :high,
94
+ example: 'xoxb-1234567890123-1234567890123-xxxxxxxxxxxxx'
95
+ },
96
+ slack_webhook: {
97
+ pattern: %r{https://hooks\.slack\.com/services/T[a-zA-Z0-9_]{8,}/B[a-zA-Z0-9_]{8,}/[a-zA-Z0-9_]{24}},
98
+ description: 'Slack Webhook URL',
99
+ severity: :medium,
100
+ example: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
101
+ },
102
+
103
+ # OAuth & JWT
104
+ jwt_token: {
105
+ pattern: %r{eyJ[A-Za-z0-9\-_=]+\.eyJ[A-Za-z0-9\-_=]+\.?[A-Za-z0-9\-_.+/=]*},
106
+ description: 'JWT Token',
107
+ severity: :medium,
108
+ example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'
109
+ },
110
+
111
+ # Generic patterns
112
+ private_key: {
113
+ pattern: /-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/,
114
+ description: 'Private Key',
115
+ severity: :critical,
116
+ example: '-----BEGIN PRIVATE KEY-----'
117
+ },
118
+ generic_api_key: {
119
+ pattern: /(?i)(api[_-]?key|apikey)[\s]*[:=][\s]*['\"]([a-zA-Z0-9_\-]{20,})['\"]/,
120
+ description: 'Generic API Key',
121
+ severity: :medium,
122
+ example: 'api_key: "abcdef1234567890abcdef1234567890"'
123
+ },
124
+ generic_secret: {
125
+ pattern: /(?i)(secret|password|passwd|pwd)[\s]*[:=][\s]*['\"]([^\s'\"]{8,})['\"]/,
126
+ description: 'Generic Secret/Password',
127
+ severity: :medium,
128
+ example: 'secret: "mysecretpassword123"'
129
+ },
130
+
131
+ # Firebase
132
+ firebase_key: {
133
+ pattern: /AIza[0-9A-Za-z\-_]{35}/,
134
+ description: 'Firebase API Key',
135
+ severity: :medium,
136
+ example: 'AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe'
137
+ },
138
+
139
+ # Twilio
140
+ twilio_api_key: {
141
+ pattern: /SK[a-z0-9]{32}/,
142
+ description: 'Twilio API Key',
143
+ severity: :high,
144
+ example: 'SKxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
145
+ },
146
+ twilio_account_sid: {
147
+ pattern: /AC[a-z0-9]{32}/,
148
+ description: 'Twilio Account SID',
149
+ severity: :low,
150
+ example: 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
151
+ },
152
+
153
+ # SendGrid
154
+ sendgrid_api_key: {
155
+ pattern: /SG\.[a-zA-Z0-9_\-]{22}\.[a-zA-Z0-9_\-]{43}/,
156
+ description: 'SendGrid API Key',
157
+ severity: :high,
158
+ example: 'SG.xxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
159
+ },
160
+
161
+ # Mailchimp
162
+ mailchimp_api_key: {
163
+ pattern: /[a-f0-9]{32}-us[0-9]{1,2}/,
164
+ description: 'Mailchimp API Key',
165
+ severity: :medium,
166
+ example: 'abcdef1234567890abcdef1234567890-us19'
167
+ },
168
+
169
+ # Square
170
+ square_access_token: {
171
+ pattern: /sq0atp-[0-9A-Za-z\-_]{22}/,
172
+ description: 'Square Access Token',
173
+ severity: :high,
174
+ example: 'sq0atp-xxxxxxxxxxxxxxxxxxxxxx'
175
+ },
176
+
177
+ # PayPal
178
+ paypal_braintree: {
179
+ pattern: /access_token\$production\$[a-z0-9]{16}\$[a-f0-9]{32}/,
180
+ description: 'PayPal Braintree Access Token',
181
+ severity: :critical,
182
+ example: 'access_token$production$xxxxxxxxxxxxxxxx$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
183
+ },
184
+
185
+ # Heroku
186
+ heroku_api_key: {
187
+ pattern: /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/,
188
+ description: 'Heroku API Key (UUID format)',
189
+ severity: :high,
190
+ example: '12345678-1234-1234-1234-123456789012'
191
+ },
192
+
193
+ # Generic Base64 secrets (often used for keys)
194
+ base64_secret: {
195
+ pattern: %r{(?i)(secret|key|token|password)[\s]*[:=][\s]*['\"]([A-Za-z0-9+/]{40,}={0,2})['\"]},
196
+ description: 'Base64 Encoded Secret',
197
+ severity: :low,
198
+ example: 'secret: "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY3ODkw"'
199
+ }
200
+ }.freeze
201
+ end
202
+ end
203
+
204
+ # rubocop:enable Lint/Syntax, Metrics/ModuleLength
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module SecureKeys
4
+ module Validation
5
+ # Common weak or test values that should never be used
6
+ WEAK_SECRETS = %w[
7
+ password password123 123456 secret test demo
8
+ admin root changeme temp default example
9
+ sample placeholder your-key-here your_api_key
10
+ xxxxxxxxxxxx 0000000000 1234567890
11
+ ].freeze
12
+ end
13
+ end
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module SecureKeys
4
+ module Validation
5
+ # Represents a single issue detected during secret validation
6
+ class ValidationIssue
7
+ attr_reader :severity, :type, :message, :recommendation
8
+
9
+ # Initialize a new validation issue
10
+ # @param severity [Symbol] The severity level (:critical, :error, :warning, :info)
11
+ # @param type [Symbol] The category of the issue (e.g. :empty_value, :weak_secret)
12
+ # @param message [String] A human-readable description of the issue
13
+ # @param recommendation [String, nil] An optional actionable recommendation for the developer
14
+ def initialize(severity:, type:, message:, recommendation:)
15
+ @severity = severity
16
+ @type = type
17
+ @message = message
18
+ @recommendation = recommendation
19
+ end
20
+
21
+ # Returns a string representation of the issue, including the recommendation if present
22
+ # @return [String] The formatted issue string
23
+ def to_s
24
+ text = "#{severity_icon} #{severity.upcase}: #{message}"
25
+ text += "\n\t\tšŸ’” #{recommendation}" if recommendation
26
+ text
27
+ end
28
+
29
+ # Returns a hash representation of the issue
30
+ # @return [Hash] The hash representation
31
+ def to_h
32
+ {
33
+ severity:,
34
+ type:,
35
+ message:,
36
+ recommendation:,
37
+ }
38
+ end
39
+
40
+ private
41
+
42
+ # Returns the appropriate icon for the severity level
43
+ # @return [String] The severity icon
44
+ def severity_icon
45
+ case severity
46
+ when :critical then 'šŸ”“'
47
+ when :error then 'āŒ'
48
+ when :warning then 'āš ļø'
49
+ when :info then 'ā„¹ļø'
50
+ else '•'
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end