gitlab-secret_detection 0.44.1 → 0.45.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: 0f9e6f5022798b252c15cce6bcbdc7ae84e9891af3a3552a1c727953868c1a27
4
+ data.tar.gz: e8ad9883653d36633fa58570b80d6da8d351feb6b9da91356f93db97f2d34466
5
5
  SHA512:
6
- metadata.gz: fb248bf045bcf4fddf5e5a3c667d49dcb5c5ce6f030a88eb68058215b4e61dc85ccd8e3f55f4540a23c006f5f3fbb945258e3e3e9a4d5e1b147e3b28d90f4658
7
- data.tar.gz: 6efaea4780addd93de280e24770a88b50b17b13df97bf0cfc84b650bc41905b07612189a8a76a4ae50feec470ae403ef130bfdfba4680a324a0463ea6ea157ca
6
+ metadata.gz: 40b41909555fd7b621805894913d305c2960793b5512f809a27521b17437839e84df43ae427f5d7106a076d6eef353d9a77864adeb1c26f94c5d0d2a718aa7a7
7
+ data.tar.gz: 7af02d50d1833419b2ae0d194bbf7ac5a8bc6cd7c0326d348e327a7cafad2fc98e454e518e04a7f6b0cb473488eac1535d1ab7831a68cae169d7e572cb3d7c0c
@@ -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
@@ -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
@@ -59,6 +71,13 @@ module Gitlab
59
71
  # for backward compatibility with existing callers and will be removed.
60
72
  # +max_findings_limit+:: Integer to limit the number of findings to be returned in the scan. Defaults
61
73
  # to 999 (+DEFAULT_MAX_FINDINGS_LIMIT+).
74
+ # +include_raw_value+:: Whether each finding should carry the substring it matched, on
75
+ # +Finding#raw_value+. Defaults to false. Callers that opt in receive secrets in
76
+ # memory and are responsible for keeping them out of logs and other sinks. In
77
+ # subprocess mode the value travels back from the child through a +Parallel+ IPC pipe.
78
+ #
79
+ # LIMITATION: one finding per rule per line, so two secrets of the *same* rule on one
80
+ # line yield one +raw_value+, the leftmost. Not an exhaustive list of a line's secrets.
62
81
  #
63
82
  # NOTE:
64
83
  # Running the scan in fork mode primarily focuses on reducing the memory consumption of the scan by
@@ -80,7 +99,8 @@ module Gitlab
80
99
  exclusions: {},
81
100
  tags: [],
82
101
  subprocess: RUN_IN_SUBPROCESS,
83
- max_findings_limit: DEFAULT_MAX_FINDINGS_LIMIT
102
+ max_findings_limit: DEFAULT_MAX_FINDINGS_LIMIT,
103
+ include_raw_value: false
84
104
  )
85
105
  return Core::Response.new(status: Core::Status::INPUT_ERROR) unless validate_scan_input(payloads)
86
106
 
@@ -97,6 +117,10 @@ module Gitlab
97
117
  )
98
118
  end
99
119
 
120
+ # Before the timeout, so the one-off compile is not charged to the scan budget, a bad
121
+ # pattern fails loudly, and forked children inherit the patterns instead of recompiling.
122
+ compiled_regexes if include_raw_value
123
+
100
124
  Timeout.timeout(timeout) do
101
125
  matched_payloads = filter_by_keywords(keyword_matcher, payloads)
102
126
 
@@ -108,7 +132,8 @@ module Gitlab
108
132
  pattern_matcher:,
109
133
  rules:,
110
134
  exclusions:,
111
- max_findings_limit:
135
+ max_findings_limit:,
136
+ include_raw_value:
112
137
  }.freeze
113
138
 
114
139
  logger.info(
@@ -291,6 +316,7 @@ module Gitlab
291
316
  pattern_matcher:,
292
317
  max_findings_limit:,
293
318
  rules:,
319
+ include_raw_value:,
294
320
  exclusions: {})
295
321
  all_applied_exclusions = Set.new
296
322
 
@@ -305,7 +331,8 @@ module Gitlab
305
331
  payload:,
306
332
  pattern_matcher:,
307
333
  exclusions:,
308
- rules:
334
+ rules:,
335
+ include_raw_value:
309
336
  )
310
337
  all_applied_exclusions.merge(applied_exclusions)
311
338
  findings
@@ -326,6 +353,7 @@ module Gitlab
326
353
  pattern_matcher:,
327
354
  max_findings_limit:,
328
355
  rules:,
356
+ include_raw_value:,
329
357
  exclusions: {}
330
358
  )
331
359
  all_applied_exclusions = Set.new
@@ -356,14 +384,17 @@ module Gitlab
356
384
  payload:,
357
385
  pattern_matcher:,
358
386
  exclusions:,
359
- rules:
387
+ rules:,
388
+ include_raw_value:
360
389
  )
361
390
  [findings, applied_exclusions]
362
391
  end
363
392
  rescue Timeout::Error => e
364
393
  logger.warn "Secret Detection scan timed out on the payload(id:#{payload.id}): #{e}"
365
394
 
366
- Core::Finding.new(payload.id, Core::Status::PAYLOAD_TIMEOUT)
395
+ # Must match the happy path's shape: the consumer destructures each element into
396
+ # `findings, applied_exclusions`. A bare Finding made `Set#merge(nil)` raise.
397
+ [[Core::Finding.new(payload.id, Core::Status::PAYLOAD_TIMEOUT)], []]
367
398
  end
368
399
 
369
400
  # Process results and collect exclusions
@@ -383,7 +414,7 @@ module Gitlab
383
414
  # Finds secrets in the given payload guarded with a timeout as a circuit breaker. It accepts
384
415
  # literal values to exclude from the input before the scan, also SD rules to exclude during
385
416
  # the scan.
386
- def find_secrets_in_payload(payload:, pattern_matcher:, rules:, exclusions: {})
417
+ def find_secrets_in_payload(payload:, pattern_matcher:, rules:, exclusions: {}, include_raw_value: false)
387
418
  findings = []
388
419
  applied_exclusions = Set.new
389
420
 
@@ -419,14 +450,8 @@ module Gitlab
419
450
 
420
451
  next if applied_rule_exclusion?(rule[:id], rule_exclusions, applied_exclusions)
421
452
 
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
453
+ findings << build_finding(
454
+ payload_id: payload.id, line_no:, rule:, rule_index: match_idx, line:, include_raw_value:
430
455
  )
431
456
  end
432
457
  end
@@ -445,6 +470,72 @@ module Gitlab
445
470
  [[Core::Finding.new(payload.id, Core::Status::SCAN_ERROR)], []]
446
471
  end
447
472
 
473
+ def build_finding(payload_id:, line_no:, rule:, rule_index:, line:, include_raw_value:)
474
+ secret = include_raw_value ? extract_raw_value(rule, rule_index, line) : nil
475
+ Core::Finding.new(
476
+ payload_id,
477
+ Core::Status::FOUND,
478
+ line_no,
479
+ rule[:id],
480
+ rule[:title] || rule[:description],
481
+ raw_value: secret
482
+ )
483
+ end
484
+
485
+ # Returns the substring of +line+ that the rule actually matched, or nil when it does not
486
+ # match. +line+ has already had raw value exclusions stripped out of it by the caller, so
487
+ # this can never return an allowlisted secret.
488
+ #
489
+ # RE2::Set reports which patterns hit but not where, so the pattern is run again to locate
490
+ # the match; with two secrets of the same rule on a line this returns the leftmost.
491
+ # +rule_index+ indexes +compiled_regexes+, which is built from the scanner's own +rules+,
492
+ # so callers must pass that same array as +rules:+ or extraction returns nil.
493
+ def extract_raw_value(rule, rule_index, line)
494
+ regex = compiled_regexes[rule_index]
495
+
496
+ # RE2 returns `true` rather than a match object when a pattern has no capturing groups and
497
+ # `submatches` is left at its default, so this argument is necessary. One more than the
498
+ # group count leaves `match[1]` nil for a group-less pattern.
499
+ match = regex.match(line, submatches: regex.number_of_capturing_groups + 1)
500
+ return unless match
501
+
502
+ return match[0] if WHOLE_MATCH_RULE_IDS.include?(rule[:id])
503
+
504
+ match[1] || match[0]
505
+ rescue StandardError => e
506
+ logger.warn(message: "Failed to extract the matched value", rule_id: rule[:id], error: e.class.name)
507
+ nil
508
+ end
509
+
510
+ # +RE2::Set+ does not expose its compiled patterns, so extraction needs its own +RE2::Regexp+
511
+ # per rule, in an Array aligned with the indices RE2::Set reports. Built once and frozen so it
512
+ # is fork-inheritable and not a shared mutable; lazy so opt-out scans pay nothing.
513
+ def compiled_regexes
514
+ strong_memoize(:compiled_regexes) do
515
+ build_compiled_regexes(rules)
516
+ end
517
+ end
518
+
519
+ # +RE2::Regexp.new+ does not raise on a bad pattern; it returns one whose +ok?+ is false and
520
+ # +number_of_capturing_groups+ is -1, which makes +submatches+ zero and flips +match+ to a
521
+ # boolean, silently nilling every raw_value for that rule. Fail loudly instead.
522
+ def build_compiled_regexes(rules)
523
+ compiled = rules.map { |rule| RE2::Regexp.new(rule[:regex]) }
524
+
525
+ invalid = rules.zip(compiled).reject { |_rule, regex| regex.ok? }
526
+ unless invalid.empty?
527
+ logger.error(
528
+ message: "Failed to compile individual secret detection rule patterns for raw value extraction",
529
+ rule_ids: invalid.map { |rule, _regex| rule[:id] },
530
+ errors: invalid.map { |_rule, regex| regex.error }
531
+ )
532
+
533
+ raise Core::Ruleset::RulesetCompilationError
534
+ end
535
+
536
+ compiled.freeze
537
+ end
538
+
448
539
  def applied_rule_exclusion?(type, rule_exclusions, applied_exclusions)
449
540
  applied_exclusion = rule_exclusions&.find { |rule_exclusion| rule_exclusion.value == type }
450
541
  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.45.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.45.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-08-28 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: grpc