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.
- checksums.yaml +4 -4
- data/README.md +360 -148
- data/lib/core/console/arguments/fetchable.rb +38 -0
- data/lib/core/console/arguments/handler.rb +7 -23
- data/lib/core/console/arguments/parser.rb +26 -0
- data/lib/core/environment/ci.rb +15 -2
- data/lib/core/generator.rb +1 -1
- data/lib/services/environment.rb +38 -0
- data/lib/validation/actions/scan.rb +126 -0
- data/lib/validation/console/arguments/parser.rb +65 -0
- data/lib/validation/console/arguments/scan/handler.rb +31 -0
- data/lib/validation/console/arguments/scan/parser.rb +61 -0
- data/lib/validation/globals/globals.rb +71 -0
- data/lib/validation/models/finding.rb +76 -0
- data/lib/validation/models/scan_result.rb +47 -0
- data/lib/validation/scanner.rb +269 -0
- data/lib/validation/utils/entropy.rb +24 -0
- data/lib/validation/utils/min_length.rb +16 -0
- data/lib/validation/utils/patterns.rb +204 -0
- data/lib/validation/utils/weak_secrets.rb +13 -0
- data/lib/validation/validation_issue.rb +55 -0
- data/lib/validation/validation_result.rb +117 -0
- data/lib/validation/validator.rb +203 -0
- data/lib/version.rb +1 -1
- metadata +20 -2
|
@@ -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
|