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,38 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module SecureKeys
4
+ module Core
5
+ module Console
6
+ module Argument
7
+ # Shared fetch/set behaviour for argument handler classes.
8
+ # Include this module inside +class << self+ so the methods become
9
+ # class-level accessors that check the handler's own +arguments+ hash
10
+ # before falling back to environment variables.
11
+ #
12
+ # The including class must expose +arguments+ as a class-level reader
13
+ # (via +attr_reader :arguments+ inside +class << self+).
14
+ module Fetchable
15
+ # Fetch an argument value, falling back to SECURE_KEYS_<KEY>,
16
+ # then the bare <KEY> environment variable, then +default+.
17
+ #
18
+ # @param key [Symbol, Array<Symbol>] The argument key (or nested key path)
19
+ # @param default [Object] The value to return when nothing is found
20
+ # @return [Object] The resolved value
21
+ def fetch(key:, default: nil)
22
+ keys = Array(key).map(&:to_sym)
23
+ joined_keys = keys.join('_').upcase
24
+ arguments.dig(*keys) || ENV["SECURE_KEYS_#{joined_keys}"] || ENV[joined_keys] || default
25
+ end
26
+
27
+ # Update a single argument value
28
+ # @param key [Symbol] The argument key to update
29
+ # @param value [Object] The new value
30
+ # @return [void]
31
+ def set(key:, value:)
32
+ arguments[key.to_sym] = value
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,9 +1,13 @@
1
+ require_relative 'fetchable'
2
+
1
3
  module SecureKeys
2
4
  module Core
3
5
  module Console
4
6
  module Argument
5
7
  class Handler
6
8
  class << self
9
+ include Fetchable
10
+
7
11
  attr_reader :arguments
8
12
  end
9
13
 
@@ -16,31 +20,11 @@ module SecureKeys
16
20
  verbose: false,
17
21
  }
18
22
 
19
- # Fetch the argument value by key
20
- # from CLI arguments or environment variables
21
- #
22
- # @param key [Array|Symbol] the argument key
23
- # @param default [String] the default value
24
- #
25
- # @return [String] the argument value
26
- def self.fetch(key:, default: nil)
27
- keys = Array(key).map(&:to_sym)
28
- joined_keys = keys.join('_').upcase
29
- @arguments.dig(*keys) || ENV["SECURE_KEYS_#{joined_keys}"] || ENV[joined_keys] || default
30
- end
31
-
32
- # Set the value of the key
33
- # @param key [Symbol] the key to be updated
34
- # @param value [String] the value to be updated
35
- def self.set(key:, value:)
36
- @arguments[key.to_sym] = value
37
- end
38
-
39
- # Append the argument value by key
23
+ # Append a hash value into a nested key, initialising it when absent
40
24
  # @param key [Symbol] the argument key
41
- # @param value [String] the argument value
25
+ # @param value [Hash] the hash to merge in
42
26
  def self.deep_merge(key:, value:)
43
- @arguments[key.to_sym] ||= {} # Initialize the key if it doesn't exist
27
+ @arguments[key.to_sym] ||= {}
44
28
  @arguments[key.to_sym].merge!(value)
45
29
  end
46
30
  end
@@ -15,6 +15,11 @@ module SecureKeys
15
15
  super('Usage: secure-keys [--options]')
16
16
  separator('')
17
17
 
18
+ # Route known subcommands before processing flags.
19
+ # Like --help and --version, subcommand handlers exit internally,
20
+ # so the generator is never reached.
21
+ route_subcommand!
22
+
18
23
  # Configure the argument parser
19
24
  configure!
20
25
  order!(into: Handler.arguments)
@@ -23,6 +28,27 @@ module SecureKeys
23
28
 
24
29
  private
25
30
 
31
+ # Known positional subcommands mapped to their handler lambdas.
32
+ # Each lambda is expected to handle its own output and exit.
33
+ SUBCOMMANDS = {
34
+ 'validate' => lambda {
35
+ require_relative '../../../validation/console/arguments/parser'
36
+ Validation::Console::Argument::Parser.new.execute
37
+ },
38
+ }.freeze
39
+
40
+ # Detect a known positional subcommand as the first ARGV token and delegate
41
+ # to its handler. Like --help and --version, the handler exits internally so
42
+ # the generator is never reached.
43
+ # @return [void]
44
+ def route_subcommand!
45
+ token = ARGV.first
46
+ return unless SUBCOMMANDS.key?(token)
47
+
48
+ ARGV.shift
49
+ SUBCOMMANDS[token].call
50
+ end
51
+
26
52
  # Configure the argument parser
27
53
  def configure!
28
54
  on('-h', '--help', 'Use the provided commands to select the params') do
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
+ require_relative '../console/logger'
4
+
3
5
  module SecureKeys
4
6
  module Core
5
7
  module Environment
@@ -8,9 +10,20 @@ module SecureKeys
8
10
  # @param key [String] the key of the environment variable to fetch
9
11
  # @return [String] the value of the environment variable
10
12
  def fetch(key:)
11
- ENV.fetch(key)
13
+ normalized_key = key.to_s.tr('-', '_').upcase
14
+
15
+ ENV[key.to_s] ||
16
+ ENV[normalized_key] ||
17
+ ENV["SECURE_KEYS_#{normalized_key}"] ||
18
+ inline_identifier_value(key)
12
19
  rescue StandardError
13
- puts "Error fetching the key: #{key} from ENV variables"
20
+ Core::Console::Logger.error(message: "Error fetching the key '#{key}' from ENV variables")
21
+ end
22
+
23
+ private
24
+
25
+ def inline_identifier_value(key)
26
+ key if key.to_s.include?(SecureKeys::Globals.key_delimiter)
14
27
  end
15
28
  end
16
29
  end
@@ -45,10 +45,10 @@ module SecureKeys
45
45
  generate_swift_package
46
46
  write_keys
47
47
  xcframework.generate
48
+ post_actions
48
49
  end
49
50
 
50
51
  xcframework.configure_xcframework_to_xcodeproj
51
- post_actions if Globals.generate_xcframework?
52
52
  end
53
53
 
54
54
  private
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ module SecureKeys
4
+ module Services
5
+ module Environment
6
+ module_function
7
+
8
+ # Fetches the value of an environment variable with support for SecureKeys prefix
9
+ # @param key [Symbol] The environment variable key to fetch
10
+ # @param default [Object] The default value to return if the environment variable is not set
11
+ # @return [Object, nil] The value of the environment variable or the default value
12
+ def fetch(key:, default: nil)
13
+ formatted_key = key.to_s.upcase
14
+ ENV[formatted_key] || ENV["SECURE_KEYS_#{formatted_key}"] || default
15
+ end
16
+
17
+ # Fetches the integer value of an environment variable with support for SecureKeys prefix
18
+ # @param key [Symbol] The environment variable key to fetch
19
+ # @param default [Object] The default value to return if the environment variable is not set or cannot be converted to an integer
20
+ # @return [Integer, nil] The integer value of the environment variable or the default value
21
+ def integer(key:, default: nil)
22
+ value = fetch(key:, default:)
23
+ Integer(value)
24
+ rescue ArgumentError, TypeError
25
+ # Returns default if it's nil or integer, otherwise, force return nil
26
+ default.is_a?(Integer) || default.nil? ? default : nil
27
+ end
28
+
29
+ def decimal(key:, default: nil)
30
+ value = fetch(key:, default:)
31
+ Float(value)
32
+ rescue ArgumentError, TypeError
33
+ # Returns default if it's nil or float, otherwise, force return nil
34
+ default.is_a?(Float) || default.nil? ? default : nil
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'json'
4
+ require_relative '../console/arguments/scan/handler'
5
+ require_relative '../../core/console/logger'
6
+ require_relative '../scanner'
7
+
8
+ module SecureKeys
9
+ module Validation
10
+ module Actions
11
+ # Executes the `validate scan` action: runs the scanner, prints a formatted
12
+ # report to the console, optionally saves a JSON report, and exits with an
13
+ # appropriate code (0 = clean, 1 = findings present).
14
+ class Scan
15
+ # Run the scan, print the report, and exit
16
+ # @return [void]
17
+ def run
18
+ result = execute_scan
19
+ print_result(result:)
20
+ save_report(result:) if Console::Argument::Scan::Handler.fetch(key: :output)
21
+ exit(result.clean? ? 0 : 1)
22
+ end
23
+
24
+ private
25
+
26
+ # Build optional scanner overrides from CLI arguments via Handler.fetch
27
+ # @return [Hash] A sparse options hash (only keys explicitly provided by the user)
28
+ def scanner_options
29
+ options = {}
30
+
31
+ if (extensions = Console::Argument::Scan::Handler.fetch(key: :extensions))
32
+ options[:extensions] = extensions.split(',').map(&:strip)
33
+ end
34
+
35
+ if (excludes = Console::Argument::Scan::Handler.fetch(key: :excludes))
36
+ options[:excludes] = excludes.split(',').map(&:strip)
37
+ end
38
+
39
+ options
40
+ end
41
+
42
+ # Run the appropriate scan based on the --staged flag
43
+ # @return [ScanResult] The result of the scan
44
+ def execute_scan
45
+ scanner = Scanner.new(options: scanner_options)
46
+
47
+ if Console::Argument::Scan::Handler.fetch(key: :staged, default: false)
48
+ Core::Console::Logger.important(message: 'Scanning staged git changes...')
49
+ scanner.scan_git_diff(staged_only: true)
50
+ else
51
+ path = Console::Argument::Scan::Handler.fetch(key: :path, default: '.')
52
+ Core::Console::Logger.important(message: "Scanning directory: #{path}")
53
+ scanner.scan_directory(path:)
54
+ end
55
+ end
56
+
57
+ # Print a formatted scan report to the console
58
+ # @param result [ScanResult] The scan result to display
59
+ # @return [void]
60
+ def print_result(result:)
61
+ separator = '-' * 70
62
+
63
+ Core::Console::Logger.message(message: separator)
64
+ Core::Console::Logger.message(message: "\tFiles scanned: #{result.files_count}")
65
+ Core::Console::Logger.message(message: "\tFindings: #{result.findings.length}")
66
+ Core::Console::Logger.message(message: separator)
67
+
68
+ if result.clean?
69
+ Core::Console::Logger.success(message: "\t✅ No secrets found")
70
+ else
71
+ print_severity_summary(result:)
72
+ Core::Console::Logger.message(message: separator)
73
+ print_findings(result:)
74
+ end
75
+
76
+ Core::Console::Logger.message(message: separator)
77
+ end
78
+
79
+ # Print a per-severity count breakdown
80
+ # @param result [ScanResult] The scan result
81
+ # @return [void]
82
+ def print_severity_summary(result:)
83
+ counts = {
84
+ critical: result.by_severity(severity: :critical).length,
85
+ high: result.by_severity(severity: :high).length,
86
+ medium: result.by_severity(severity: :medium).length,
87
+ low: result.by_severity(severity: :low).length,
88
+ }
89
+
90
+ Core::Console::Logger.error(message: "\t🔴 Critical: #{counts[:critical]}") if counts[:critical].positive?
91
+ Core::Console::Logger.warning(message: "\t🟠 High: #{counts[:high]}") if counts[:high].positive?
92
+ Core::Console::Logger.warning(message: "\t🟡 Medium: #{counts[:medium]}") if counts[:medium].positive?
93
+ Core::Console::Logger.message(message: "\t🔵 Low: #{counts[:low]}") if counts[:low].positive?
94
+ end
95
+
96
+ # Print each finding grouped by severity level
97
+ # @param result [ScanResult] The scan result
98
+ # @return [void]
99
+ def print_findings(result:)
100
+ %i[critical high medium low].each do |severity|
101
+ findings = result.by_severity(severity:)
102
+ next if findings.empty?
103
+
104
+ Core::Console::Logger.message(message: "\t#{severity.to_s.upcase} (#{findings.length}):")
105
+
106
+ findings.each do |finding|
107
+ Core::Console::Logger.message(message: "\t\t#{finding}")
108
+ Core::Console::Logger.message(message: "\t\t└ #{finding.full_line}")
109
+ end
110
+
111
+ Core::Console::Logger.message(message: '')
112
+ end
113
+ end
114
+
115
+ # Save the scan result as a pretty-printed JSON report
116
+ # @param result [ScanResult] The scan result to serialise
117
+ # @return [void]
118
+ def save_report(result:)
119
+ output = Console::Argument::Scan::Handler.fetch(key: :output)
120
+ File.write(output, JSON.pretty_generate(result.to_h))
121
+ Core::Console::Logger.success(message: "Report saved to: #{output}")
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'optparse'
4
+ require_relative '../../../core/console/logger'
5
+ require_relative 'scan/handler'
6
+ require_relative 'scan/parser'
7
+ require_relative '../../actions/scan'
8
+
9
+ module SecureKeys
10
+ module Validation
11
+ module Console
12
+ module Argument
13
+ # Routes the `validate` subcommand to the appropriate sub-parser and action.
14
+ # The constructor reads the subcommand token from ARGV; +execute+ then
15
+ # delegates to the matching sub-parser and runs the action.
16
+ class Parser < OptionParser
17
+ # Initialize the validate argument parser and capture the subcommand token
18
+ def initialize
19
+ super('Usage: secure-keys validate [subcommand] [--options]')
20
+ separator('')
21
+ configure!
22
+ @subcommand = ARGV.shift
23
+ end
24
+
25
+ # Dispatch to the correct sub-parser and action based on the subcommand
26
+ # @return [void]
27
+ def execute
28
+ case @subcommand
29
+ when 'scan'
30
+ Scan::Parser.new
31
+ Actions::Scan.new.run
32
+ when nil, '--help', '-h'
33
+ puts self
34
+ exit(0)
35
+ else
36
+ Core::Console::Logger.error(message: "Unknown validate subcommand: '#{@subcommand}'")
37
+ puts self
38
+ exit(1)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # Configure the validate-level help text and available subcommands
45
+ # @return [void]
46
+ def configure!
47
+ on('-h', '--help', 'Show help for the validate command') do
48
+ puts self
49
+ exit(0)
50
+ end
51
+ separator('')
52
+ separator('Subcommands:')
53
+ separator("\tscan [path] Scan a directory or staged git changes for exposed secrets")
54
+ separator('')
55
+ separator('Examples:')
56
+ separator("\tsecure-keys validate scan")
57
+ separator("\tsecure-keys validate scan ./src")
58
+ separator("\tsecure-keys validate scan --staged")
59
+ separator("\tsecure-keys validate scan --output report.json")
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../../../../core/console/arguments/fetchable'
4
+
5
+ module SecureKeys
6
+ module Validation
7
+ module Console
8
+ module Argument
9
+ module Scan
10
+ # Stores and provides access to the resolved CLI arguments for the scan subcommand
11
+ class Handler
12
+ class << self
13
+ include Core::Console::Argument::Fetchable
14
+
15
+ attr_reader :arguments
16
+ end
17
+
18
+ # Default argument values for the scan subcommand
19
+ @arguments = {
20
+ path: '.',
21
+ staged: false,
22
+ output: nil,
23
+ extensions: nil,
24
+ excludes: nil,
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'optparse'
4
+ require_relative 'handler'
5
+ require_relative '../../../../core/console/arguments/handler'
6
+
7
+ module SecureKeys
8
+ module Validation
9
+ module Console
10
+ module Argument
11
+ module Scan
12
+ # Parses CLI options for the `secure-keys validate scan` subcommand.
13
+ # Uses +parse!+ so options and positional arguments may appear in any order;
14
+ # after parsing, any remaining ARGV token is treated as the scan path.
15
+ class Parser < OptionParser
16
+ # Initialize the scan parser, process ARGV, and store results in Handler
17
+ def initialize
18
+ super('Usage: secure-keys validate scan [path] [--options]')
19
+ separator('')
20
+ configure!
21
+ parse!(into: Handler.arguments)
22
+ Handler.set(key: :path, value: ARGV.shift) unless ARGV.empty?
23
+ end
24
+
25
+ private
26
+
27
+ # Define all accepted options for the scan subcommand
28
+ # @return [void]
29
+ def configure!
30
+ on('-h', '--help', 'Show help for the scan subcommand') do
31
+ puts self
32
+ exit(0)
33
+ end
34
+ on('--staged', 'Scan staged git changes instead of a directory (default: false)') do
35
+ Handler.set(key: :staged, value: true)
36
+ end
37
+ on(
38
+ '-o', '--output FILE',
39
+ String,
40
+ 'Save the scan report as JSON to FILE'
41
+ )
42
+ on(
43
+ '--extensions EXTENSIONS',
44
+ String,
45
+ 'Comma-separated file extensions to scan (e.g. .rb,.swift)'
46
+ )
47
+ on(
48
+ '--excludes EXCLUDES',
49
+ String,
50
+ 'Comma-separated directory names to exclude from the scan'
51
+ )
52
+ on('--verbose', 'Enable verbose output (default: false)') do
53
+ Core::Console::Argument::Handler.set(key: :verbose, value: true)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -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