secure-keys 1.1.6 → 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,71 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../../services/environment'
4
+
5
+ module SecureKeys
6
+ module Validation
7
+ module Globals
8
+ module_function
9
+
10
+ # Returns the minimum length for an API key
11
+ # @return [Integer] The minimum length for an API key
12
+ def api_key_length
13
+ Services::Environment.integer(key: :api_key_length, default: 20)
14
+ end
15
+
16
+ # Returns the minimum length for a token
17
+ # @return [Integer] The minimum length for a token
18
+ def token_length
19
+ Services::Environment.integer(key: :token_length, default: 20)
20
+ end
21
+
22
+ # Returns the minimum length for a secret
23
+ # @return [Integer] The minimum length for a secret
24
+ def secret_length
25
+ Services::Environment.integer(key: :secret_length, default: 16)
26
+ end
27
+
28
+ # Returns the minimum length for a password
29
+ # @return [Integer] The minimum length for a password
30
+ def password_length
31
+ Services::Environment.integer(key: :password_length, default: 12)
32
+ end
33
+
34
+ # Returns the minimum length for a generic key
35
+ # @return [Integer] The minimum length for a key
36
+ def key_length
37
+ Services::Environment.integer(key: :key_length, default: 16)
38
+ end
39
+
40
+ # Returns the default file extensions to scan
41
+ # @return [Array<String>] The default file extensions
42
+ def default_scan_extensions
43
+ Services::Environment.fetch(
44
+ key: :scan_extensions,
45
+ default: '.swift,.m,.mm,.h,.rb,.py,.js,.ts,.java,.kt,.yaml,.yml,.json,.env,.plist'
46
+ ).split(',')
47
+ end
48
+
49
+ # Returns the default directory and file names to exclude from scanning
50
+ # @return [Array<String>] The default exclude patterns
51
+ def default_scan_excludes
52
+ Services::Environment.fetch(
53
+ key: :scan_excludes,
54
+ default: '.git,node_modules,Pods,build,DerivedData,.build,vendor,.bundle,Carthage,.secure-keys,coverage'
55
+ ).split(',')
56
+ end
57
+
58
+ # Returns the maximum directory traversal depth for scanning
59
+ # @return [Integer] The maximum scan depth
60
+ def max_scan_depth
61
+ Services::Environment.integer(key: :max_scan_depth, default: 10)
62
+ end
63
+
64
+ # Returns the minimum Shannon entropy threshold for secret validation
65
+ # @return [Float] The minimum entropy threshold
66
+ def min_entropy_threshold
67
+ Services::Environment.decimal(key: :min_entropy_threshold, default: 3.0)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module SecureKeys
4
+ module Validation
5
+ # Represents a single secret detected during a file or git diff scan
6
+ class Finding
7
+ attr_reader :file, :line, :column, :type, :description, :severity,
8
+ :matched_text, :full_line, :is_addition
9
+
10
+ # Initialize a new finding
11
+ # @param file [String] The file path where the secret was found
12
+ # @param line [Integer] The line number where the secret was found
13
+ # @param column [Integer] The column offset of the match within the line
14
+ # @param type [Symbol] The pattern type that matched (e.g. :github_token, :aws_access_key)
15
+ # @param description [String] A human-readable description of the secret type
16
+ # @param severity [Symbol] The severity level (:low, :medium, :high, :critical)
17
+ # @param matched_text [String] The masked matched text, safe for display
18
+ # @param full_line [String] The full trimmed line of code containing the secret
19
+ # @param is_addition [Boolean] Whether this line is an addition in a git diff (default: false)
20
+ def initialize(file:, line:, column:, type:, description:, severity:,
21
+ matched_text:, full_line:, is_addition: false)
22
+ @file = file
23
+ @line = line
24
+ @column = column
25
+ @type = type
26
+ @description = description
27
+ @severity = severity
28
+ @matched_text = matched_text
29
+ @full_line = full_line
30
+ @is_addition = is_addition
31
+ end
32
+
33
+ # Check if this finding came from a git diff addition
34
+ # @return [Boolean] true if the line is a git diff addition
35
+ def addition?
36
+ is_addition
37
+ end
38
+
39
+ # Returns a one-line string representation of the finding
40
+ # @return [String] The formatted finding string
41
+ def to_s
42
+ "#{severity_icon} #{file}:#{line}:#{column} [#{type}] #{description} — #{matched_text}"
43
+ end
44
+
45
+ # Returns a hash representation of the finding
46
+ # @return [Hash] The hash representation
47
+ def to_h
48
+ {
49
+ file:,
50
+ line:,
51
+ column:,
52
+ type:,
53
+ description:,
54
+ severity:,
55
+ matched_text:,
56
+ full_line:,
57
+ is_addition:,
58
+ }
59
+ end
60
+
61
+ private
62
+
63
+ # Returns the appropriate icon for the severity level
64
+ # @return [String] The severity icon
65
+ def severity_icon
66
+ case severity
67
+ when :critical then '🔴'
68
+ when :high then '🟠'
69
+ when :medium then '🟡'
70
+ when :low then '🔵'
71
+ else '⚪'
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -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