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.
- 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 +28 -2
- data/lib/core/console/arguments/xcframework/parser.rb +1 -1
- data/lib/core/console/shell.rb +1 -3
- data/lib/core/environment/ci.rb +15 -2
- data/lib/core/generator.rb +9 -9
- data/lib/core/utils/extensions/kernel.rb +2 -2
- data/lib/core/utils/swift/package.rb +1 -1
- data/lib/core/utils/swift/writer.rb +2 -4
- data/lib/core/utils/swift/xcframework.rb +1 -1
- data/lib/keys.rb +6 -6
- 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 +2 -1
- metadata +21 -58
|
@@ -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
|