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 +4 -4
- data/README.md +27 -0
- data/lib/gitlab/secret_detection/core/finding.rb +22 -2
- data/lib/gitlab/secret_detection/core/ruleset.rb +152 -16
- data/lib/gitlab/secret_detection/core/scanner.rb +160 -36
- data/lib/gitlab/secret_detection/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31d3996d42590cabd29d5198d67cc9ee716f31232d207183f98986a3dc0f4bdb
|
|
4
|
+
data.tar.gz: 44811e65092f594a602a9cc2f399d98ac047569dc7bac7f359a18190fa18c54e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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:
|
|
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 :
|
|
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
|
|
49
|
-
ruleset_path:
|
|
105
|
+
message: "Parsing #{custom ? 'custom' : 'bundled'} ruleset file",
|
|
106
|
+
ruleset_path: file_path
|
|
50
107
|
)
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
61
|
-
|
|
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
|
-
|
|
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(@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
224
|
-
# nil when the
|
|
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(
|
|
259
|
+
def build_keyword_matcher(keywords)
|
|
227
260
|
logger.info(
|
|
228
261
|
message: "Creating RE2 Keyword Matcher",
|
|
229
|
-
|
|
262
|
+
keywords_count: keywords.size
|
|
230
263
|
)
|
|
231
264
|
|
|
232
|
-
|
|
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 =
|
|
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:
|
|
276
|
+
keywords: keywords.to_a
|
|
250
277
|
)
|
|
251
278
|
|
|
252
|
-
RE2
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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-
|
|
13
|
+
date: 2026-09-28 00:00:00.000000000 Z
|
|
14
14
|
dependencies:
|
|
15
15
|
- !ruby/object:Gem::Dependency
|
|
16
16
|
name: grpc
|