gitlab-secret_detection 0.45.0 → 0.46.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f9e6f5022798b252c15cce6bcbdc7ae84e9891af3a3552a1c727953868c1a27
4
- data.tar.gz: e8ad9883653d36633fa58570b80d6da8d351feb6b9da91356f93db97f2d34466
3
+ metadata.gz: 31d3996d42590cabd29d5198d67cc9ee716f31232d207183f98986a3dc0f4bdb
4
+ data.tar.gz: 44811e65092f594a602a9cc2f399d98ac047569dc7bac7f359a18190fa18c54e
5
5
  SHA512:
6
- metadata.gz: 40b41909555fd7b621805894913d305c2960793b5512f809a27521b17437839e84df43ae427f5d7106a076d6eef353d9a77864adeb1c26f94c5d0d2a718aa7a7
7
- data.tar.gz: 7af02d50d1833419b2ae0d194bbf7ac5a8bc6cd7c0326d348e327a7cafad2fc98e454e518e04a7f6b0cb473488eac1535d1ab7831a68cae169d7e572cb3d7c0c
6
+ metadata.gz: 5ab9cea777bf959289694831001f055df76b80265df7af578e167b25096403952f4a659f442351f301d6b38a31865a579d47d9b83f68f5201dfbecd1f60679c3
7
+ data.tar.gz: 520ad2c9f1a97c9576460e0b28071564acf9cadf3516118051e91bb4c09e1ef103b86a054573efe8755d3def33a3b899c74baa01488e326c5a6fa32fcffedb0e
data/README.md CHANGED
@@ -88,6 +88,33 @@ The command is called when running tests as well as when building the Dockerfile
88
88
  The secret_push_protection_rules.toml file is added to .gitignore to
89
89
  avoid checking in changes.
90
90
 
91
+ ### Custom ruleset file (gem only)
92
+
93
+ A consumer of the ruby gem can use a custom ruleset file instead of the bundled
94
+ secret_push_protection_rules.toml file. Set the `GITLAB_SD_RULESET_FILE_PATH` environment variable
95
+ to the path of the custom file. Set it in the environment of the process that runs the scan.
96
+
97
+ - The file must be a TOML file with a `.toml` extension.
98
+ - A relative path is resolved against the working directory of the process. `~` is not expanded.
99
+ - A `path:` argument to `Gitlab::SecretDetection::Core::Ruleset.new` has priority over the environment variable.
100
+ - The rules are loaded one time for each `Ruleset` object. A change to the file has an effect only on a new load.
101
+ - The gRPC service also reads this environment variable, but this use is not supported.
102
+
103
+ The custom file must have a non-empty `[[rules]]` array. Each rule must have these fields:
104
+
105
+ | Field | Required | Description |
106
+ |---|---|---|
107
+ | `id` | Yes | A non-empty string. Each `id` must be unique in the file. |
108
+ | `regex` | Yes | A non-empty string that RE2 can compile. |
109
+ | `title` or `description` | Yes | The scanner uses the `title` as the finding description. It uses the `description` only if the `title` is missing, empty, or has only white space. The value that the scanner uses must be a non-empty string. |
110
+ | `keywords` | No | An array of non-empty strings. |
111
+
112
+ The scanner uses the keyword prefilter only for the rules that have keywords. It matches the rules that have no keywords on all payloads.
113
+
114
+ If the custom file cannot be loaded or fails validation, the gem logs an error with the message
115
+ `Failed to load custom secret detection ruleset. Using the default ruleset.` and loads the bundled file.
116
+ The log entry contains the error class and the full error message. For a validation error, the message lists each problem on its own line. For a TOML syntax error, the message contains the line of the file that has the error.
117
+
91
118
  ## Generating a ruby gem
92
119
 
93
120
  In the project directory, run `make gem` command in the terminal that builds a ruby gem(ex: `secret_detection-0.1.0.gem`) in the root of
@@ -2,11 +2,15 @@
2
2
 
3
3
  require 'toml-rb'
4
4
  require 'logger'
5
+ require 're2'
6
+ require_relative '../utils/memoize'
5
7
 
6
8
  module Gitlab
7
9
  module SecretDetection
8
10
  module Core
9
11
  class Ruleset
12
+ include ::Gitlab::SecretDetection::Utils::StrongMemoize
13
+
10
14
  # RulesetParseError is thrown when the code fails to parse the
11
15
  # ruleset file from the given path
12
16
  RulesetParseError = Class.new(StandardError)
@@ -15,10 +19,42 @@ module Gitlab
15
19
  # the predefined rulesets
16
20
  RulesetCompilationError = Class.new(StandardError)
17
21
 
18
- # file path where the secrets ruleset file is located
22
+ ERROR_MESSAGES = {
23
+ version_extraction_failed: "Failed to extract Secret Detection Ruleset version from ruleset file",
24
+ parse_failed: "Failed to parse local secret detection ruleset: %<error>s",
25
+ fallback: "Failed to load custom secret detection ruleset. Using the default ruleset.",
26
+ validation_failed: "Ruleset validation failed:\n%<errors>s",
27
+ invalid_extension: "Ruleset file must have a .toml extension",
28
+ empty_rules: "Rules must be a non-empty Array of [[rules]] tables",
29
+ duplicate_ids: "Rule ids must be unique",
30
+ rule_error: "rules[%<index>d]: %<error>s",
31
+ rule_not_table: "Rule must be a toml table",
32
+ invalid_id: "Rule id must be a non-empty String",
33
+ empty_regex: "Rule regex must be a non-empty String",
34
+ invalid_regex: "Rule regex is not valid: %<error>s",
35
+ invalid_description: "Rule title or description must be a non-empty String",
36
+ invalid_keywords: "Rule keywords must be either empty or an Array of non-empty Strings"
37
+ }.freeze
38
+
39
+ # RulesetValidationError is raised when a custom ruleset file fails validation
40
+ class RulesetValidationError < RulesetParseError
41
+ def initialize(errors)
42
+ super(format(ERROR_MESSAGES[:validation_failed], errors: errors.join("\n")))
43
+ end
44
+ end
45
+
46
+ # Default ruleset file path
19
47
  RULESET_FILE_PATH = File.expand_path('secret_push_protection_rules.toml', __dir__)
20
48
 
21
- def initialize(path: RULESET_FILE_PATH, logger: Logger.new($stdout))
49
+ RULESET_FILE_PATH_ENV = 'GITLAB_SD_RULESET_FILE_PATH'
50
+
51
+ # Returns the rule title, or the rule description when the title is nil or blank.
52
+ def self.finding_description(rule)
53
+ title = rule[:title]
54
+ title.is_a?(String) && !title.strip.empty? ? title : rule[:description]
55
+ end
56
+
57
+ def initialize(path: nil, logger: Logger.new($stdout))
22
58
  @path = path
23
59
  @logger = logger
24
60
  end
@@ -29,38 +65,138 @@ module Gitlab
29
65
  @rule_data = parse_ruleset
30
66
  end
31
67
 
68
+ # Before a load, it reads the file at resolved_path.
32
69
  def extract_ruleset_version
33
- @ruleset_version ||= if File.readable?(RULESET_FILE_PATH)
34
- first_line = File.open(RULESET_FILE_PATH, &:gets)
35
- first_line&.split(":")&.[](1)&.strip
36
- end
70
+ file_path = loaded_path || resolved_path
71
+ return unless File.readable?(file_path)
72
+
73
+ first_line = File.open(file_path, &:gets)
74
+ first_line&.split(":")&.[](1)&.strip # Gets "0.24.3" from "# rule-set version: 0.24.3"
37
75
  rescue StandardError => e
38
- logger.error(message: "Failed to extract Secret Detection Ruleset version from ruleset file: #{e.message}")
76
+ logger.error(message: ERROR_MESSAGES[:version_extraction_failed], error: e.message)
77
+ nil
39
78
  end
40
79
 
41
80
  private
42
81
 
43
- attr_reader :path, :logger
82
+ attr_reader :logger, :loaded_path
44
83
 
45
- # parses given ruleset file and returns the parsed rules
46
84
  def parse_ruleset
85
+ load_from(resolved_path)
86
+ rescue RulesetParseError => e
87
+ raise if resolved_path == RULESET_FILE_PATH
88
+
89
+ log_fallback(e)
90
+ load_from(RULESET_FILE_PATH)
91
+ end
92
+
93
+ def resolved_path
94
+ return File.absolute_path(@path) if @path
95
+
96
+ env_value = ENV.fetch(RULESET_FILE_PATH_ENV, '').strip
97
+ env_value.empty? ? RULESET_FILE_PATH : File.absolute_path(env_value)
98
+ end
99
+ strong_memoize_attr :resolved_path
100
+
101
+ def load_from(file_path)
102
+ custom = file_path != RULESET_FILE_PATH
103
+
47
104
  logger.info(
48
- message: "Parsing local ruleset file",
49
- ruleset_path: RULESET_FILE_PATH
105
+ message: "Parsing #{custom ? 'custom' : 'bundled'} ruleset file",
106
+ ruleset_path: file_path
50
107
  )
51
- rules_data = TomlRB.load_file(path, symbolize_keys: true).freeze
52
- ruleset_version = extract_ruleset_version
108
+ check_toml_extension!(file_path) if custom
109
+
110
+ rules_data = TomlRB.load_file(file_path, symbolize_keys: true).freeze
111
+ validate_rules!(rules_data) if custom
53
112
 
113
+ @loaded_path = file_path
54
114
  logger.info(
55
115
  message: "Ruleset details fetched for running Secret Detection scan",
116
+ ruleset_path: file_path,
56
117
  total_rules: rules_data[:rules]&.length,
57
- ruleset_version:
118
+ ruleset_version: extract_ruleset_version,
119
+ custom_ruleset: custom
58
120
  )
59
121
  rules_data[:rules].freeze
60
- rescue StandardError => e
61
- logger.error(message: "Failed to parse local secret detection ruleset: #{e.message}")
122
+ rescue RulesetParseError
123
+ raise
124
+ # A deeply nested TOML file raises SystemStackError.
125
+ rescue StandardError, SystemStackError => e
126
+ logger.error(message: format(ERROR_MESSAGES[:parse_failed], error: e.message)) unless custom
62
127
  raise RulesetParseError, e
63
128
  end
129
+
130
+ def check_toml_extension!(file_path)
131
+ return if File.extname(file_path).casecmp?('.toml')
132
+
133
+ raise RulesetValidationError, [ERROR_MESSAGES[:invalid_extension]]
134
+ end
135
+
136
+ # Ruleset validation ensuring it has all the required
137
+ # fields. Applicable to custom ruleset input only.
138
+ def validate_rules!(rules_data)
139
+ rules = rules_data[:rules]
140
+ raise RulesetValidationError, [ERROR_MESSAGES[:empty_rules]] unless rules.is_a?(Array) && !rules.empty?
141
+
142
+ errors = rules.each_with_index.flat_map do |rule, index|
143
+ rule_errors(rule).map { |error| format(ERROR_MESSAGES[:rule_error], index:, error:) }
144
+ end
145
+
146
+ if errors.empty? && rules.map { |rule| rule[:id] }.uniq.size < rules.size
147
+ errors << ERROR_MESSAGES[:duplicate_ids]
148
+ end
149
+
150
+ raise RulesetValidationError, errors unless errors.empty?
151
+ end
152
+
153
+ def rule_errors(rule)
154
+ return [ERROR_MESSAGES[:rule_not_table]] unless rule.is_a?(Hash)
155
+
156
+ finding_description = self.class.finding_description(rule)
157
+ keywords = rule[:keywords]
158
+
159
+ errors = []
160
+ errors << ERROR_MESSAGES[:invalid_id] unless non_empty_string?(rule[:id])
161
+ errors << regex_error(rule[:regex])
162
+ errors << ERROR_MESSAGES[:invalid_description] unless non_empty_string?(finding_description)
163
+
164
+ unless keywords.nil? || (keywords.is_a?(Array) && keywords.all? { |keyword| non_empty_string?(keyword) })
165
+ errors << ERROR_MESSAGES[:invalid_keywords]
166
+ end
167
+
168
+ errors.compact
169
+ end
170
+
171
+ def regex_error(regex)
172
+ return ERROR_MESSAGES[:empty_regex] unless non_empty_string?(regex)
173
+
174
+ # Without log_errors: false, RE2 writes the pattern to STDERR.
175
+ compiled = RE2::Regexp.new(regex, log_errors: false)
176
+ format(ERROR_MESSAGES[:invalid_regex], error: compiled.error) unless compiled.ok?
177
+ end
178
+
179
+ def non_empty_string?(value)
180
+ value.is_a?(String) && !value.strip.empty?
181
+ end
182
+
183
+ def log_fallback(error)
184
+ logger.error(
185
+ message: ERROR_MESSAGES[:fallback],
186
+ custom_ruleset_path: resolved_path,
187
+ default_ruleset_path: RULESET_FILE_PATH,
188
+ ruleset_path_env: RULESET_FILE_PATH_ENV,
189
+ error_class: fallback_error_class(error),
190
+ error_message: error.message
191
+ )
192
+ end
193
+
194
+ # A validation error gets an unrelated cause when #rules runs inside a rescue block.
195
+ def fallback_error_class(error)
196
+ return error.class.name if error.is_a?(RulesetValidationError)
197
+
198
+ (error.cause || error).class.name
199
+ end
64
200
  end
65
201
  end
66
202
  end
@@ -46,10 +46,14 @@ module Gitlab
46
46
  # Raises +RulesetCompilationError+ in case the regex pattern compilation fails.
47
47
  def initialize(rules:, logger: Logger.new($stdout))
48
48
  @logger = logger
49
- @rules = select_push_protection_rules(rules)
49
+ selected_rules = select_push_protection_rules(rules)
50
+ keywordless_rules, keyword_rules = selected_rules.partition { |rule| Array(rule[:keywords]).empty? }
51
+ # Rules with no keywords come first, so that the indexes of their pattern matcher are also indexes in @rules.
52
+ @rules = (keywordless_rules + keyword_rules).freeze
50
53
  @keywords = create_keywords(@rules)
51
- @keyword_matcher = build_keyword_matcher(@rules)
54
+ @keyword_matcher = build_keyword_matcher(@keywords)
52
55
  @pattern_matcher = build_pattern_matcher(@rules)
56
+ @keywordless_pattern_matcher = build_pattern_matcher(keywordless_rules) unless keywordless_rules.empty?
53
57
  end
54
58
 
55
59
  # Runs Secret Detection scan on the list of given payloads. Both the total scan duration and
@@ -123,16 +127,17 @@ module Gitlab
123
127
 
124
128
  Timeout.timeout(timeout) do
125
129
  matched_payloads = filter_by_keywords(keyword_matcher, payloads)
130
+ # Rules with no keywords also apply to the payloads that the keyword prefilter drops.
131
+ unmatched_payloads = keywordless_pattern_matcher ? payloads - matched_payloads : []
126
132
 
127
- next Core::Response.new(status: Core::Status::NOT_FOUND) if matched_payloads.empty?
133
+ if matched_payloads.empty? && unmatched_payloads.empty?
134
+ next Core::Response.new(status: Core::Status::NOT_FOUND)
135
+ end
128
136
 
129
137
  scan_args = {
130
- payloads: matched_payloads,
131
138
  payload_timeout:,
132
- pattern_matcher:,
133
139
  rules:,
134
140
  exclusions:,
135
- max_findings_limit:,
136
141
  include_raw_value:
137
142
  }.freeze
138
143
 
@@ -141,14 +146,17 @@ module Gitlab
141
146
  timeout:,
142
147
  payload_timeout:,
143
148
  given_total_payloads: payloads.length,
144
- scannable_payloads_post_keyword_filter: matched_payloads.length,
149
+ keyword_matched_payloads: matched_payloads.length,
150
+ payloads_for_keywordless_rules: unmatched_payloads.length,
145
151
  active_rules: rules.length,
146
152
  run_in_subprocess: subprocess,
147
153
  max_findings_limit:,
148
154
  given_exclusions: format_exclusions_hash(exclusions)
149
155
  )
150
156
 
151
- secrets, applied_exclusions = subprocess ? run_scan_within_subprocess(**scan_args) : run_scan(**scan_args)
157
+ secrets, applied_exclusions = scan_payload_groups(
158
+ matched_payloads, unmatched_payloads, subprocess:, max_findings_limit:, **scan_args
159
+ )
152
160
 
153
161
  scan_status = overall_scan_status(secrets)
154
162
 
@@ -168,7 +176,7 @@ module Gitlab
168
176
 
169
177
  private
170
178
 
171
- attr_reader :logger, :rules, :keywords, :pattern_matcher, :keyword_matcher
179
+ attr_reader :logger, :rules, :keywords, :pattern_matcher, :keyword_matcher, :keywordless_pattern_matcher
172
180
 
173
181
  # A rule applies to push protection when its structured `scanningCapabilities`
174
182
  # field includes `pushProtection`. Rules without the field are included for
@@ -245,36 +253,41 @@ module Gitlab
245
253
  secrets_keywords.freeze
246
254
  end
247
255
 
248
- # Builds RE2 keyword matcher from the keywords of the given rules. Returns
249
- # nil when the rules define no keywords, in which case the keyword-based
256
+ # Builds RE2 keyword matcher from the given set of keywords. Returns
257
+ # nil when the set is empty, in which case the keyword-based
250
258
  # payload prefilter is skipped.
251
- def build_keyword_matcher(rules)
259
+ def build_keyword_matcher(keywords)
252
260
  logger.info(
253
261
  message: "Creating RE2 Keyword Matcher",
254
- rules_count: rules.length
262
+ keywords_count: keywords.size
255
263
  )
256
264
 
257
- include_keywords = Set.new
258
-
259
- rules.each do |rule|
260
- include_keywords.merge(rule[:keywords]) unless rule[:keywords].nil?
261
- end
262
-
263
- if include_keywords.empty?
265
+ if keywords.empty?
264
266
  logger.error(
265
267
  message: "No rule keywords found in the given rules, returning empty RE2 Keyword Matcher"
266
268
  )
267
269
  return nil
268
270
  end
269
271
 
270
- keywords_regex = include_keywords.map { |keyword| RE2::Regexp.quote(keyword) }.join('|')
272
+ keywords_regex = keywords.map { |keyword| RE2::Regexp.quote(keyword) }.join('|')
271
273
 
272
274
  logger.debug(
273
275
  message: "Creating RE2 Keyword Matcher with set of rule keywords",
274
- keywords: include_keywords.to_a
276
+ keywords: keywords.to_a
275
277
  )
276
278
 
277
- RE2("(#{keywords_regex})")
279
+ # Without log_errors: false, RE2 writes the pattern to STDERR.
280
+ keyword_matcher = RE2::Regexp.new("(#{keywords_regex})", log_errors: false)
281
+ return keyword_matcher if keyword_matcher.ok?
282
+
283
+ # A matcher that does not compile matches no payload.
284
+ logger.error(
285
+ message: "Failed to compile the RE2 Keyword Matcher. The scan skips the keyword prefilter and directly " \
286
+ "scans all payloads with rule regex patterns.",
287
+ keywords_count: keywords.size,
288
+ error: keyword_matcher.error
289
+ )
290
+ nil
278
291
  end
279
292
 
280
293
  def filter_by_keywords(keyword_matcher, payloads)
@@ -307,6 +320,26 @@ module Gitlab
307
320
  matched_payloads
308
321
  end
309
322
 
323
+ # Scans the payloads that have a keyword with all rules, and the other payloads
324
+ # with only the rules that have no keywords.
325
+ def scan_payload_groups(matched_payloads, unmatched_payloads, subprocess:, max_findings_limit:, **scan_args)
326
+ secrets = []
327
+ applied_exclusions = []
328
+ groups = [[matched_payloads, pattern_matcher], [unmatched_payloads, keywordless_pattern_matcher]]
329
+
330
+ groups.each do |group, matcher|
331
+ remaining_limit = max_findings_limit - secrets.length
332
+ next if group.empty? || remaining_limit <= 0
333
+
334
+ args = scan_args.merge(payloads: group, pattern_matcher: matcher, max_findings_limit: remaining_limit)
335
+ found, excluded = subprocess ? run_scan_within_subprocess(**args) : run_scan(**args)
336
+ secrets.concat(found)
337
+ applied_exclusions |= excluded
338
+ end
339
+
340
+ [secrets, applied_exclusions]
341
+ end
342
+
310
343
  # Runs the secret detection scan on the given list of payloads. It accepts
311
344
  # literal values to exclude from the input before the scan, also SD rules to exclude during
312
345
  # the scan when performed on the payloads.
@@ -477,7 +510,7 @@ module Gitlab
477
510
  Core::Status::FOUND,
478
511
  line_no,
479
512
  rule[:id],
480
- rule[:title] || rule[:description],
513
+ Core::Ruleset.finding_description(rule),
481
514
  raw_value: secret
482
515
  )
483
516
  end
@@ -5,7 +5,7 @@ module Gitlab
5
5
  class Gem
6
6
  # Ensure to maintain the same version in CHANGELOG file.
7
7
  # More details available under 'Release Process' section in the README.md file.
8
- VERSION = "0.45.0"
8
+ VERSION = "0.46.0"
9
9
 
10
10
  # SD_ENV env var is used to determine which environment the
11
11
  # server is running. This var is defined in `.runway/env-<env>.yml` files.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-secret_detection
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.45.0
4
+ version: 0.46.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - group::secret detection
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-08-28 00:00:00.000000000 Z
13
+ date: 2026-09-28 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: grpc