gitlab-secret_detection 0.44.1 → 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: f87ef52f7c7fcc5ca568db74d13514ae77976e7691cd09c3abaa84f0b9c05f19
4
- data.tar.gz: 325da324f3ff92288d230522ea6fd7bf9457d81118c64cebccc2e0072dd564ff
3
+ metadata.gz: 31d3996d42590cabd29d5198d67cc9ee716f31232d207183f98986a3dc0f4bdb
4
+ data.tar.gz: 44811e65092f594a602a9cc2f399d98ac047569dc7bac7f359a18190fa18c54e
5
5
  SHA512:
6
- metadata.gz: fb248bf045bcf4fddf5e5a3c667d49dcb5c5ce6f030a88eb68058215b4e61dc85ccd8e3f55f4540a23c006f5f3fbb945258e3e3e9a4d5e1b147e3b28d90f4658
7
- data.tar.gz: 6efaea4780addd93de280e24770a88b50b17b13df97bf0cfc84b650bc41905b07612189a8a76a4ae50feec470ae403ef130bfdfba4680a324a0463ea6ea157ca
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
@@ -1,24 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Gitlab
4
6
  module SecretDetection
5
7
  module Core
6
8
  # Finding is a data object representing a secret finding identified within a payload
7
9
  class Finding
8
- attr_reader :payload_id, :status, :line_number, :type, :description
10
+ attr_reader :payload_id, :status, :line_number, :type, :description, :raw_value
9
11
 
10
- def initialize(payload_id, status, line_number = nil, type = nil, description = nil)
12
+ def initialize(payload_id, status, line_number = nil, type = nil, description = nil, raw_value: nil)
11
13
  @payload_id = payload_id
12
14
  @status = status
13
15
  @line_number = line_number
14
16
  @type = type
15
17
  @description = description
18
+ @raw_value = raw_value
16
19
  end
17
20
 
18
21
  def ==(other)
19
22
  self.class == other.class && other.state == state
20
23
  end
21
24
 
25
+ # +raw_value+ is omitted here, and from +state+ and +inspect+, so a secret cannot leak.
22
26
  def to_h
23
27
  {
24
28
  payload_id:,
@@ -29,6 +33,22 @@ module Gitlab
29
33
  }
30
34
  end
31
35
 
36
+ def as_json(_options = nil)
37
+ to_h
38
+ end
39
+
40
+ def to_json(*args)
41
+ to_h.to_json(*args)
42
+ end
43
+
44
+ # The default +inspect+ would dump +raw_value+ in plain text, reaching `p`, error contexts,
45
+ # and anything holding a Finding (+Response+ included).
46
+ def inspect
47
+ "#<#{self.class.name} payload_id=#{payload_id.inspect} status=#{status.inspect} " \
48
+ "line_number=#{line_number.inspect} type=#{type.inspect} " \
49
+ "description=#{description.inspect} raw_value=#{raw_value.nil? ? 'nil' : '[FILTERED]'}>"
50
+ end
51
+
32
52
  protected
33
53
 
34
54
  def state
@@ -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
@@ -5,12 +5,15 @@ require 'logger'
5
5
  require 'timeout'
6
6
  require 'English'
7
7
  require 'parallel'
8
+ require_relative '../utils/memoize'
8
9
 
9
10
  module Gitlab
10
11
  module SecretDetection
11
12
  module Core
12
13
  # Scan is responsible for running Secret Detection scan operation
13
14
  class Scanner
15
+ include ::Gitlab::SecretDetection::Utils::StrongMemoize
16
+
14
17
  # default time limit(in seconds) for running the scan operation per invocation
15
18
  DEFAULT_SCAN_TIMEOUT_SECS = 180 # 3 minutes
16
19
  # default time limit(in seconds) for running the scan operation on a single payload
@@ -25,6 +28,15 @@ module Gitlab
25
28
  RUN_IN_SUBPROCESS = ENV.fetch('GITLAB_SD_RUN_IN_SUBPROCESS', false)
26
29
  # Default limit for max findings to be returned in the scan
27
30
  DEFAULT_MAX_FINDINGS_LIMIT = 999
31
+ # Rules whose capture group is incorrectly capturing part of the secret
32
+ # instead of entire secret. We capture the whole match instead of captured
33
+ # match when extracting the secret
34
+ WHOLE_MATCH_RULE_IDS = Set[
35
+ 'Adobe Client Secret',
36
+ 'ContentfulPersonalAccessToken',
37
+ 'Github App Token',
38
+ 'Slack token'
39
+ ].freeze
28
40
 
29
41
  # Initializes the instance with logger along with following operations:
30
42
  # 1. Filter the parsed ruleset down to rules applicable to push protection based on
@@ -34,10 +46,14 @@ module Gitlab
34
46
  # Raises +RulesetCompilationError+ in case the regex pattern compilation fails.
35
47
  def initialize(rules:, logger: Logger.new($stdout))
36
48
  @logger = logger
37
- @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
38
53
  @keywords = create_keywords(@rules)
39
- @keyword_matcher = build_keyword_matcher(@rules)
54
+ @keyword_matcher = build_keyword_matcher(@keywords)
40
55
  @pattern_matcher = build_pattern_matcher(@rules)
56
+ @keywordless_pattern_matcher = build_pattern_matcher(keywordless_rules) unless keywordless_rules.empty?
41
57
  end
42
58
 
43
59
  # Runs Secret Detection scan on the list of given payloads. Both the total scan duration and
@@ -59,6 +75,13 @@ module Gitlab
59
75
  # for backward compatibility with existing callers and will be removed.
60
76
  # +max_findings_limit+:: Integer to limit the number of findings to be returned in the scan. Defaults
61
77
  # to 999 (+DEFAULT_MAX_FINDINGS_LIMIT+).
78
+ # +include_raw_value+:: Whether each finding should carry the substring it matched, on
79
+ # +Finding#raw_value+. Defaults to false. Callers that opt in receive secrets in
80
+ # memory and are responsible for keeping them out of logs and other sinks. In
81
+ # subprocess mode the value travels back from the child through a +Parallel+ IPC pipe.
82
+ #
83
+ # LIMITATION: one finding per rule per line, so two secrets of the *same* rule on one
84
+ # line yield one +raw_value+, the leftmost. Not an exhaustive list of a line's secrets.
62
85
  #
63
86
  # NOTE:
64
87
  # Running the scan in fork mode primarily focuses on reducing the memory consumption of the scan by
@@ -80,7 +103,8 @@ module Gitlab
80
103
  exclusions: {},
81
104
  tags: [],
82
105
  subprocess: RUN_IN_SUBPROCESS,
83
- max_findings_limit: DEFAULT_MAX_FINDINGS_LIMIT
106
+ max_findings_limit: DEFAULT_MAX_FINDINGS_LIMIT,
107
+ include_raw_value: false
84
108
  )
85
109
  return Core::Response.new(status: Core::Status::INPUT_ERROR) unless validate_scan_input(payloads)
86
110
 
@@ -97,18 +121,24 @@ module Gitlab
97
121
  )
98
122
  end
99
123
 
124
+ # Before the timeout, so the one-off compile is not charged to the scan budget, a bad
125
+ # pattern fails loudly, and forked children inherit the patterns instead of recompiling.
126
+ compiled_regexes if include_raw_value
127
+
100
128
  Timeout.timeout(timeout) do
101
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 : []
102
132
 
103
- 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
104
136
 
105
137
  scan_args = {
106
- payloads: matched_payloads,
107
138
  payload_timeout:,
108
- pattern_matcher:,
109
139
  rules:,
110
140
  exclusions:,
111
- max_findings_limit:
141
+ include_raw_value:
112
142
  }.freeze
113
143
 
114
144
  logger.info(
@@ -116,14 +146,17 @@ module Gitlab
116
146
  timeout:,
117
147
  payload_timeout:,
118
148
  given_total_payloads: payloads.length,
119
- scannable_payloads_post_keyword_filter: matched_payloads.length,
149
+ keyword_matched_payloads: matched_payloads.length,
150
+ payloads_for_keywordless_rules: unmatched_payloads.length,
120
151
  active_rules: rules.length,
121
152
  run_in_subprocess: subprocess,
122
153
  max_findings_limit:,
123
154
  given_exclusions: format_exclusions_hash(exclusions)
124
155
  )
125
156
 
126
- 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
+ )
127
160
 
128
161
  scan_status = overall_scan_status(secrets)
129
162
 
@@ -143,7 +176,7 @@ module Gitlab
143
176
 
144
177
  private
145
178
 
146
- attr_reader :logger, :rules, :keywords, :pattern_matcher, :keyword_matcher
179
+ attr_reader :logger, :rules, :keywords, :pattern_matcher, :keyword_matcher, :keywordless_pattern_matcher
147
180
 
148
181
  # A rule applies to push protection when its structured `scanningCapabilities`
149
182
  # field includes `pushProtection`. Rules without the field are included for
@@ -220,36 +253,41 @@ module Gitlab
220
253
  secrets_keywords.freeze
221
254
  end
222
255
 
223
- # Builds RE2 keyword matcher from the keywords of the given rules. Returns
224
- # 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
225
258
  # payload prefilter is skipped.
226
- def build_keyword_matcher(rules)
259
+ def build_keyword_matcher(keywords)
227
260
  logger.info(
228
261
  message: "Creating RE2 Keyword Matcher",
229
- rules_count: rules.length
262
+ keywords_count: keywords.size
230
263
  )
231
264
 
232
- include_keywords = Set.new
233
-
234
- rules.each do |rule|
235
- include_keywords.merge(rule[:keywords]) unless rule[:keywords].nil?
236
- end
237
-
238
- if include_keywords.empty?
265
+ if keywords.empty?
239
266
  logger.error(
240
267
  message: "No rule keywords found in the given rules, returning empty RE2 Keyword Matcher"
241
268
  )
242
269
  return nil
243
270
  end
244
271
 
245
- keywords_regex = include_keywords.map { |keyword| RE2::Regexp.quote(keyword) }.join('|')
272
+ keywords_regex = keywords.map { |keyword| RE2::Regexp.quote(keyword) }.join('|')
246
273
 
247
274
  logger.debug(
248
275
  message: "Creating RE2 Keyword Matcher with set of rule keywords",
249
- keywords: include_keywords.to_a
276
+ keywords: keywords.to_a
250
277
  )
251
278
 
252
- 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
253
291
  end
254
292
 
255
293
  def filter_by_keywords(keyword_matcher, payloads)
@@ -282,6 +320,26 @@ module Gitlab
282
320
  matched_payloads
283
321
  end
284
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
+
285
343
  # Runs the secret detection scan on the given list of payloads. It accepts
286
344
  # literal values to exclude from the input before the scan, also SD rules to exclude during
287
345
  # the scan when performed on the payloads.
@@ -291,6 +349,7 @@ module Gitlab
291
349
  pattern_matcher:,
292
350
  max_findings_limit:,
293
351
  rules:,
352
+ include_raw_value:,
294
353
  exclusions: {})
295
354
  all_applied_exclusions = Set.new
296
355
 
@@ -305,7 +364,8 @@ module Gitlab
305
364
  payload:,
306
365
  pattern_matcher:,
307
366
  exclusions:,
308
- rules:
367
+ rules:,
368
+ include_raw_value:
309
369
  )
310
370
  all_applied_exclusions.merge(applied_exclusions)
311
371
  findings
@@ -326,6 +386,7 @@ module Gitlab
326
386
  pattern_matcher:,
327
387
  max_findings_limit:,
328
388
  rules:,
389
+ include_raw_value:,
329
390
  exclusions: {}
330
391
  )
331
392
  all_applied_exclusions = Set.new
@@ -356,14 +417,17 @@ module Gitlab
356
417
  payload:,
357
418
  pattern_matcher:,
358
419
  exclusions:,
359
- rules:
420
+ rules:,
421
+ include_raw_value:
360
422
  )
361
423
  [findings, applied_exclusions]
362
424
  end
363
425
  rescue Timeout::Error => e
364
426
  logger.warn "Secret Detection scan timed out on the payload(id:#{payload.id}): #{e}"
365
427
 
366
- Core::Finding.new(payload.id, Core::Status::PAYLOAD_TIMEOUT)
428
+ # Must match the happy path's shape: the consumer destructures each element into
429
+ # `findings, applied_exclusions`. A bare Finding made `Set#merge(nil)` raise.
430
+ [[Core::Finding.new(payload.id, Core::Status::PAYLOAD_TIMEOUT)], []]
367
431
  end
368
432
 
369
433
  # Process results and collect exclusions
@@ -383,7 +447,7 @@ module Gitlab
383
447
  # Finds secrets in the given payload guarded with a timeout as a circuit breaker. It accepts
384
448
  # literal values to exclude from the input before the scan, also SD rules to exclude during
385
449
  # the scan.
386
- def find_secrets_in_payload(payload:, pattern_matcher:, rules:, exclusions: {})
450
+ def find_secrets_in_payload(payload:, pattern_matcher:, rules:, exclusions: {}, include_raw_value: false)
387
451
  findings = []
388
452
  applied_exclusions = Set.new
389
453
 
@@ -419,14 +483,8 @@ module Gitlab
419
483
 
420
484
  next if applied_rule_exclusion?(rule[:id], rule_exclusions, applied_exclusions)
421
485
 
422
- title = rule[:title].nil? ? rule[:description] : rule[:title]
423
-
424
- findings << Core::Finding.new(
425
- payload.id,
426
- Core::Status::FOUND,
427
- line_no,
428
- rule[:id],
429
- title
486
+ findings << build_finding(
487
+ payload_id: payload.id, line_no:, rule:, rule_index: match_idx, line:, include_raw_value:
430
488
  )
431
489
  end
432
490
  end
@@ -445,6 +503,72 @@ module Gitlab
445
503
  [[Core::Finding.new(payload.id, Core::Status::SCAN_ERROR)], []]
446
504
  end
447
505
 
506
+ def build_finding(payload_id:, line_no:, rule:, rule_index:, line:, include_raw_value:)
507
+ secret = include_raw_value ? extract_raw_value(rule, rule_index, line) : nil
508
+ Core::Finding.new(
509
+ payload_id,
510
+ Core::Status::FOUND,
511
+ line_no,
512
+ rule[:id],
513
+ Core::Ruleset.finding_description(rule),
514
+ raw_value: secret
515
+ )
516
+ end
517
+
518
+ # Returns the substring of +line+ that the rule actually matched, or nil when it does not
519
+ # match. +line+ has already had raw value exclusions stripped out of it by the caller, so
520
+ # this can never return an allowlisted secret.
521
+ #
522
+ # RE2::Set reports which patterns hit but not where, so the pattern is run again to locate
523
+ # the match; with two secrets of the same rule on a line this returns the leftmost.
524
+ # +rule_index+ indexes +compiled_regexes+, which is built from the scanner's own +rules+,
525
+ # so callers must pass that same array as +rules:+ or extraction returns nil.
526
+ def extract_raw_value(rule, rule_index, line)
527
+ regex = compiled_regexes[rule_index]
528
+
529
+ # RE2 returns `true` rather than a match object when a pattern has no capturing groups and
530
+ # `submatches` is left at its default, so this argument is necessary. One more than the
531
+ # group count leaves `match[1]` nil for a group-less pattern.
532
+ match = regex.match(line, submatches: regex.number_of_capturing_groups + 1)
533
+ return unless match
534
+
535
+ return match[0] if WHOLE_MATCH_RULE_IDS.include?(rule[:id])
536
+
537
+ match[1] || match[0]
538
+ rescue StandardError => e
539
+ logger.warn(message: "Failed to extract the matched value", rule_id: rule[:id], error: e.class.name)
540
+ nil
541
+ end
542
+
543
+ # +RE2::Set+ does not expose its compiled patterns, so extraction needs its own +RE2::Regexp+
544
+ # per rule, in an Array aligned with the indices RE2::Set reports. Built once and frozen so it
545
+ # is fork-inheritable and not a shared mutable; lazy so opt-out scans pay nothing.
546
+ def compiled_regexes
547
+ strong_memoize(:compiled_regexes) do
548
+ build_compiled_regexes(rules)
549
+ end
550
+ end
551
+
552
+ # +RE2::Regexp.new+ does not raise on a bad pattern; it returns one whose +ok?+ is false and
553
+ # +number_of_capturing_groups+ is -1, which makes +submatches+ zero and flips +match+ to a
554
+ # boolean, silently nilling every raw_value for that rule. Fail loudly instead.
555
+ def build_compiled_regexes(rules)
556
+ compiled = rules.map { |rule| RE2::Regexp.new(rule[:regex]) }
557
+
558
+ invalid = rules.zip(compiled).reject { |_rule, regex| regex.ok? }
559
+ unless invalid.empty?
560
+ logger.error(
561
+ message: "Failed to compile individual secret detection rule patterns for raw value extraction",
562
+ rule_ids: invalid.map { |rule, _regex| rule[:id] },
563
+ errors: invalid.map { |_rule, regex| regex.error }
564
+ )
565
+
566
+ raise Core::Ruleset::RulesetCompilationError
567
+ end
568
+
569
+ compiled.freeze
570
+ end
571
+
448
572
  def applied_rule_exclusion?(type, rule_exclusions, applied_exclusions)
449
573
  applied_exclusion = rule_exclusions&.find { |rule_exclusion| rule_exclusion.value == type }
450
574
  applied_exclusion && (applied_exclusions << applied_exclusion)
@@ -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.44.1"
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.44.1
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-03 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