gitlab-secret_detection 0.1.0 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitLab
4
+ module SecretDetection
5
+ module Core
6
+ # Response is the data object returned by the scan operation with the following structure
7
+ #
8
+ # +status+:: One of values from GitLab::SecretDetection::Core::Status indicating the scan operation's status
9
+ # +results+:: Array of GitLab::SecretDetection::Core::Finding values. Default value is nil.
10
+ class Response
11
+ attr_reader :status, :results
12
+
13
+ def initialize(status, results = [])
14
+ @status = status
15
+ @results = results
16
+ end
17
+
18
+ def ==(other)
19
+ self.class == other.class && other.state == state
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ status:,
25
+ results: results&.map(&:to_h)
26
+ }
27
+ end
28
+
29
+ protected
30
+
31
+ def state
32
+ [status, results]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'toml-rb'
4
+ require 'logger'
5
+
6
+ module GitLab
7
+ module SecretDetection
8
+ module Core
9
+ class Ruleset
10
+ # file path where the secrets ruleset file is located
11
+ RULESET_FILE_PATH = File.expand_path('gitleaks.toml', __dir__)
12
+
13
+ def initialize(path: RULESET_FILE_PATH)
14
+ @path = path
15
+ end
16
+
17
+ def rules(force_fetch: false)
18
+ return @rule_data unless @rule_data.nil? || force_fetch
19
+
20
+ @rule_data ||= parse_ruleset
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :path
26
+
27
+ # parses given ruleset file and returns the parsed rules
28
+ def parse_ruleset
29
+ # rule_file_content = File.read(path)
30
+ rules_data = TomlRB.load_file(path, symbolize_keys: true).freeze
31
+ rules_data[:rules].freeze
32
+ rescue StandardError => e
33
+ logger.error "Failed to parse secret detection ruleset from '#{path}' path: #{e}"
34
+ raise Core::Scanner::RulesetParseError
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 're2'
4
+ require 'logger'
5
+ require 'timeout'
6
+ require 'English'
7
+
8
+ module GitLab
9
+ module SecretDetection
10
+ module Core
11
+ # Scan is responsible for running Secret Detection scan operation
12
+ class Scanner
13
+ # RulesetParseError is thrown when the code fails to parse the
14
+ # ruleset file from the given path
15
+ RulesetParseError = Class.new(StandardError)
16
+
17
+ # RulesetCompilationError is thrown when the code fails to compile
18
+ # the predefined rulesets
19
+ RulesetCompilationError = Class.new(StandardError)
20
+
21
+ # default time limit(in seconds) for running the scan operation per invocation
22
+ DEFAULT_SCAN_TIMEOUT_SECS = 180 # 3 minutes
23
+ # default time limit(in seconds) for running the scan operation on a single payload
24
+ DEFAULT_PAYLOAD_TIMEOUT_SECS = 30 # 30 seconds
25
+ # Tags used for creating default pattern matcher
26
+ DEFAULT_PATTERN_MATCHER_TAGS = ['gitlab_blocking'].freeze
27
+
28
+ # Initializes the instance with logger along with following operations:
29
+ # 1. Extract keywords from the parsed ruleset to use it for matching keywords before regex operation.
30
+ # 2. Build and Compile rule regex patterns obtained from the ruleset with +DEFAULT_PATTERN_MATCHER_TAGS+
31
+ # tags. Raises +RulesetCompilationError+ in case the regex pattern compilation fails.
32
+ def initialize(rules:, logger: Logger.new($stdout))
33
+ @logger = logger
34
+ @rules = rules
35
+ @keywords = create_keywords(rules)
36
+ @default_keyword_matcher = build_keyword_matcher(
37
+ tags: DEFAULT_PATTERN_MATCHER_TAGS,
38
+ include_missing_tags: false
39
+ )
40
+ @default_pattern_matcher = build_pattern_matcher(
41
+ tags: DEFAULT_PATTERN_MATCHER_TAGS,
42
+ include_missing_tags: false
43
+ ) # includes only gitlab_blocking rules
44
+ end
45
+
46
+ # Runs Secret Detection scan on the list of given payloads. Both the total scan duration and
47
+ # the duration for each payload is time bound via +timeout+ and +payload_timeout+ respectively.
48
+ #
49
+ # +payloads+:: Array of payloads where each payload should have `id` and `data` properties.
50
+ # +timeout+:: No of seconds(accepts floating point for smaller time values) to limit the total scan duration
51
+ # +payload_timeout+:: No of seconds(accepts floating point for smaller time values) to limit
52
+ # the scan duration on each payload
53
+ # +rule_exclusions+:: Array of rules to exclude from the ruleset used for the scan. Each rule is represented
54
+ # by its ID. For example: `gitlab_personal_access_token` for representing GitLab Personal Access
55
+ # Token. By default, no rule is excluded from the ruleset.
56
+ # +allow_values+:: Array of raw values to exclude from the scan.
57
+ # +tags+:: Array of tag values to filter from the default ruleset when determining the rules used for the scan.
58
+ # For example: Add `gitlab_blocking` to include only rules for Push Protection. Defaults to
59
+ # [`gitlab_blocking`] (+DEFAULT_PATTERN_MATCHER_TAGS+).
60
+ #
61
+ # Returns an instance of GitLab::SecretDetection::Core::Response by following below structure:
62
+ # {
63
+ # status: One of the Core::Status values
64
+ # results: [SecretDetection::Finding]
65
+ # }
66
+ #
67
+ def secrets_scan(
68
+ payloads,
69
+ timeout: DEFAULT_SCAN_TIMEOUT_SECS,
70
+ payload_timeout: DEFAULT_PAYLOAD_TIMEOUT_SECS,
71
+ rule_exclusions: [],
72
+ allow_values: [],
73
+ tags: DEFAULT_PATTERN_MATCHER_TAGS
74
+ )
75
+
76
+ return Core::Response.new(Core::Status::INPUT_ERROR) unless validate_scan_input(payloads)
77
+
78
+ # assign defaults since grpc passing zero timeout value to `Timeout.timeout(..)` makes it effectively useless.
79
+ timeout = DEFAULT_SCAN_TIMEOUT_SECS unless timeout.positive?
80
+ payload_timeout = DEFAULT_PAYLOAD_TIMEOUT_SECS unless payload_timeout.positive?
81
+ tags = DEFAULT_PATTERN_MATCHER_TAGS if tags.empty?
82
+
83
+ Timeout.timeout(timeout) do
84
+ keyword_matcher = build_keyword_matcher(tags:)
85
+
86
+ matched_payloads = filter_by_keywords(keyword_matcher, payloads)
87
+
88
+ next Core::Response.new(Core::Status::NOT_FOUND) if matched_payloads.empty?
89
+
90
+ secrets = run_scan(
91
+ payloads: matched_payloads, payload_timeout:,
92
+ pattern_matcher: build_pattern_matcher(tags:),
93
+ allow_values:, rule_exclusions:
94
+ )
95
+
96
+ scan_status = overall_scan_status(secrets)
97
+
98
+ Core::Response.new(scan_status, secrets)
99
+ end
100
+ rescue Timeout::Error => e
101
+ logger.error "Secret detection operation timed out: #{e}"
102
+
103
+ Core::Response.new(Core::Status::SCAN_TIMEOUT)
104
+ end
105
+
106
+ private
107
+
108
+ attr_reader :logger, :rules, :keywords, :default_pattern_matcher, :default_keyword_matcher
109
+
110
+ # Builds RE2::Set pattern matcher for the given combination of rules
111
+ # and tags. It also allows a choice(via `include_missing_tags`) to consider rules
112
+ # for pattern matching that do not have `tags` property defined. If the given tags
113
+ # are same as +DEFAULT_PATTERN_MATCHER_TAGS+ then returns the eagerly loaded default
114
+ # pattern matcher created during initialization.
115
+ def build_pattern_matcher(tags:, include_missing_tags: false)
116
+ return default_pattern_matcher if tags.eql?(DEFAULT_PATTERN_MATCHER_TAGS) && !default_pattern_matcher.nil?
117
+
118
+ matcher = RE2::Set.new
119
+
120
+ rules.each do |rule|
121
+ rule_tags = rule[:tags]
122
+
123
+ include_rule = if tags.empty?
124
+ true
125
+ elsif rule_tags
126
+ tags.intersect?(rule_tags)
127
+ else
128
+ include_missing_tags
129
+ end
130
+
131
+ matcher.add(rule[:regex]) if include_rule
132
+ end
133
+
134
+ unless matcher.compile
135
+ logger.error "Failed to compile secret detection rulesets in RE::Set"
136
+
137
+ raise RulesetCompilationError
138
+ end
139
+
140
+ matcher
141
+ end
142
+
143
+ # Creates and returns the unique set of rule matching keywords
144
+ def create_keywords(rules)
145
+ secrets_keywords = Set.new
146
+
147
+ rules.each do |rule|
148
+ secrets_keywords.merge rule[:keywords] unless rule[:keywords].nil?
149
+ end
150
+
151
+ secrets_keywords.freeze
152
+ end
153
+
154
+ def build_keyword_matcher(tags:, include_missing_tags: false)
155
+ return default_keyword_matcher if tags.eql?(DEFAULT_PATTERN_MATCHER_TAGS) && !default_keyword_matcher.nil?
156
+
157
+ include_keywords = Set.new
158
+
159
+ rules.each do |rule|
160
+ rule_tags = rule.fetch(:tags, [])
161
+
162
+ next if rule_tags.empty? && !include_missing_tags
163
+ next unless rule_tags.intersect?(tags)
164
+
165
+ include_keywords.merge(rule[:keywords]) unless rule[:keywords].nil?
166
+ end
167
+
168
+ return nil if include_keywords.empty?
169
+
170
+ keywords_regex = include_keywords.join('|')
171
+
172
+ RE2("\\b(#{keywords_regex})")
173
+ end
174
+
175
+ def filter_by_keywords(keyword_matcher, payloads)
176
+ return [] if keyword_matcher.nil?
177
+
178
+ matched_payloads = []
179
+ payloads.each do |payload|
180
+ next unless keyword_matcher.partial_match?(payload.data)
181
+
182
+ matched_payloads << payload
183
+ end
184
+
185
+ matched_payloads.freeze
186
+ end
187
+
188
+ # Runs the secret detection scan on the given list of payloads. It accepts
189
+ # literal values to exclude from the input before the scan, also SD rules to exclude during
190
+ # the scan when performed on the payloads.
191
+ def run_scan(
192
+ payloads:, payload_timeout:, pattern_matcher:, allow_values: [], rule_exclusions: [])
193
+ payloads.flat_map do |payload|
194
+ Timeout.timeout(payload_timeout) do
195
+ find_secrets_in_payload(
196
+ payload:,
197
+ pattern_matcher:,
198
+ allow_values:, rule_exclusions:
199
+ )
200
+ end
201
+ rescue Timeout::Error => e
202
+ logger.error "Secret Detection scan timed out on the payload(id:#{payload.id}): #{e}"
203
+ Core::Finding.new(payload.id,
204
+ Core::Status::PAYLOAD_TIMEOUT)
205
+ end
206
+ end
207
+
208
+ # Finds secrets in the given payload guarded with a timeout as a circuit breaker. It accepts
209
+ # literal values to exclude from the input before the scan, also SD rules to exclude during
210
+ # the scan.
211
+ def find_secrets_in_payload(payload:, pattern_matcher:, allow_values: [], rule_exclusions: [])
212
+ findings = []
213
+
214
+ payload.data
215
+ .each_line($INPUT_RECORD_SEPARATOR, chomp: true)
216
+ .each_with_index do |line, index|
217
+ unless allow_values.empty?
218
+ allow_values.each do |value|
219
+ line.gsub!(value, '') # replace input that doesn't contain allowed value in it
220
+ end
221
+ end
222
+
223
+ next if line.empty?
224
+
225
+ line_no = index + 1
226
+
227
+ matches = pattern_matcher.match(line, exception: false) # returns indices of matched patterns
228
+
229
+ matches.each do |match_idx|
230
+ rule = rules[match_idx]
231
+
232
+ next if rule_exclusions.include?(rule[:id])
233
+
234
+ findings << Core::Finding.new(payload.id, Core::Status::FOUND, line_no, rule[:id], rule[:description])
235
+ end
236
+ end
237
+
238
+ findings.freeze
239
+ rescue StandardError => e
240
+ logger.error "Secret Detection scan failed on the payload(id:#{payload.id}): #{e}"
241
+
242
+ Core::Finding.new(payload.id, Core::Status::SCAN_ERROR)
243
+ end
244
+
245
+ # Validates the given payloads by verifying the type and
246
+ # presence of `id` and `data` fields necessary for the scan
247
+ def validate_scan_input(payloads)
248
+ return false if payloads.nil? || !payloads.instance_of?(Array)
249
+
250
+ payloads.all? do |payload|
251
+ payload.respond_to?(:id) && payload.respond_to?(:data)
252
+ end
253
+ end
254
+
255
+ # Returns the status of the overall scan request
256
+ # based on the detected secret findings found in the input payloads
257
+ def overall_scan_status(found_secrets)
258
+ return Core::Status::NOT_FOUND if found_secrets.empty?
259
+
260
+ timed_out_payloads = found_secrets.count { |el| el.status == Core::Status::PAYLOAD_TIMEOUT }
261
+
262
+ case timed_out_payloads
263
+ when 0
264
+ Core::Status::FOUND
265
+ when found_secrets.length
266
+ Core::Status::SCAN_TIMEOUT
267
+ else
268
+ Core::Status::FOUND_WITH_ERRORS
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitLab
4
+ module SecretDetection
5
+ module Core
6
+ # All the possible statuses emitted by the scan operation
7
+ class Status
8
+ FOUND = 1 # When scan operation completes with one or more findings
9
+ FOUND_WITH_ERRORS = 2 # When scan operation completes with one or more findings along with some errors
10
+ SCAN_TIMEOUT = 3 # When the scan operation runs beyond given time out
11
+ PAYLOAD_TIMEOUT = 4 # When the scan operation on a payload runs beyond given time out
12
+ SCAN_ERROR = 5 # When the scan operation fails due to regex error
13
+ INPUT_ERROR = 6 # When the scan operation fails due to invalid input
14
+ NOT_FOUND = 7 # When scan operation completes with zero findings
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'core/finding'
4
+ require_relative 'core/response'
5
+ require_relative 'core/status'
6
+ require_relative 'core/scanner'
7
+ require_relative 'core/ruleset'
8
+
9
+ module GitLab
10
+ module SecretDetection
11
+ module Core
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../generated/secret_detection_pb'
4
+ require_relative '../generated/secret_detection_services_pb'
5
+
6
+ module GitLab
7
+ module SecretDetection
8
+ module GRPC
9
+ class Client
10
+ # TODO add implementation
11
+ def scan
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def scan_stream
16
+ raise NotImplementedError
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # source: secret_detection.proto
4
+
5
+ require 'google/protobuf'
6
+
7
+
8
+ descriptor_data = "\n\x16secret_detection.proto\x12\x17gitlab.secret_detection\"\xdb\x03\n\x0bScanRequest\x12>\n\x08payloads\x18\x01 \x03(\x0b\x32,.gitlab.secret_detection.ScanRequest.Payload\x12\x19\n\x0ctimeout_secs\x18\x02 \x01(\x02H\x00\x88\x01\x01\x12!\n\x14payload_timeout_secs\x18\x03 \x01(\x02H\x01\x88\x01\x01\x12\x42\n\tallowlist\x18\x04 \x03(\x0b\x32/.gitlab.secret_detection.ScanRequest.AllowEntry\x12\x0c\n\x04tags\x18\x05 \x03(\t\x1a#\n\x07Payload\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\t\x1a_\n\nAllowEntry\x12\x42\n\nallow_type\x18\x01 \x01(\x0e\x32..gitlab.secret_detection.ScanRequest.AllowType\x12\r\n\x05value\x18\x02 \x01(\t\"L\n\tAllowType\x12\x15\n\x11\x41LLOW_UNSPECIFIED\x10\x00\x12\x13\n\x0f\x41LLOW_RULE_TYPE\x10\x01\x12\x13\n\x0f\x41LLOW_RAW_VALUE\x10\x02\x42\x0f\n\r_timeout_secsB\x17\n\x15_payload_timeout_secs\"\xe3\x04\n\x0cScanResponse\x12\x12\n\x05\x65rror\x18\x01 \x01(\tH\x00\x88\x01\x01\x12>\n\x07results\x18\x02 \x03(\x0b\x32-.gitlab.secret_detection.ScanResponse.Finding\x12<\n\x06status\x18\x03 \x01(\x0e\x32,.gitlab.secret_detection.ScanResponse.Status\x1a\xe9\x01\n\x07\x46inding\x12\x12\n\npayload_id\x18\x01 \x01(\t\x12<\n\x06status\x18\x02 \x01(\x0e\x32,.gitlab.secret_detection.ScanResponse.Status\x12\x11\n\x04type\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x04 \x01(\tH\x01\x88\x01\x01\x12\x18\n\x0bline_number\x18\x05 \x01(\x05H\x02\x88\x01\x01\x12\x12\n\x05\x65rror\x18\x06 \x01(\tH\x03\x88\x01\x01\x42\x07\n\x05_typeB\x0e\n\x0c_descriptionB\x0e\n\x0c_line_numberB\x08\n\x06_error\"\xca\x01\n\x06Status\x12\x16\n\x12STATUS_UNSPECIFIED\x10\x00\x12\x10\n\x0cSTATUS_FOUND\x10\x01\x12\x1c\n\x18STATUS_FOUND_WITH_ERRORS\x10\x02\x12\x17\n\x13STATUS_SCAN_TIMEOUT\x10\x03\x12\x1a\n\x16STATUS_PAYLOAD_TIMEOUT\x10\x04\x12\x15\n\x11STATUS_SCAN_ERROR\x10\x05\x12\x16\n\x12STATUS_INPUT_ERROR\x10\x06\x12\x14\n\x10STATUS_NOT_FOUND\x10\x07\x42\x08\n\x06_error2\xc1\x01\n\x07Scanner\x12U\n\x04Scan\x12$.gitlab.secret_detection.ScanRequest\x1a%.gitlab.secret_detection.ScanResponse\"\x00\x12_\n\nScanStream\x12$.gitlab.secret_detection.ScanRequest\x1a%.gitlab.secret_detection.ScanResponse\"\x00(\x01\x30\x01\x42 \xea\x02\x1dGitLab::SecretDetection::GRPCb\x06proto3"
9
+
10
+ pool = Google::Protobuf::DescriptorPool.generated_pool
11
+ pool.add_serialized_file(descriptor_data)
12
+
13
+ module GitLab
14
+ module SecretDetection
15
+ module GRPC
16
+ ScanRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.secret_detection.ScanRequest").msgclass
17
+ ScanRequest::Payload = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.secret_detection.ScanRequest.Payload").msgclass
18
+ ScanRequest::AllowEntry = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.secret_detection.ScanRequest.AllowEntry").msgclass
19
+ ScanRequest::AllowType = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.secret_detection.ScanRequest.AllowType").enummodule
20
+ ScanResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.secret_detection.ScanResponse").msgclass
21
+ ScanResponse::Finding = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.secret_detection.ScanResponse.Finding").msgclass
22
+ ScanResponse::Status = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gitlab.secret_detection.ScanResponse.Status").enummodule
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
2
+ # Source: secret_detection.proto for package 'GitLab.SecretDetection.GRPC'
3
+
4
+ require 'grpc'
5
+ require 'secret_detection_pb'
6
+
7
+ module GitLab
8
+ module SecretDetection
9
+ module GRPC
10
+ module Scanner
11
+ # Scanner service that scans given payloads and returns findings
12
+ class Service
13
+
14
+ include ::GRPC::GenericService
15
+
16
+ self.marshal_class_method = :encode
17
+ self.unmarshal_class_method = :decode
18
+ self.service_name = 'gitlab.secret_detection.Scanner'
19
+
20
+ # Runs secret detection scan for the given request
21
+ rpc :Scan, ::GitLab::SecretDetection::GRPC::ScanRequest, ::GitLab::SecretDetection::GRPC::ScanResponse
22
+ # Runs bi-directional streaming of scans for the given stream of requests with a stream of responses
23
+ rpc :ScanStream, stream(::GitLab::SecretDetection::GRPC::ScanRequest), stream(::GitLab::SecretDetection::GRPC::ScanResponse)
24
+ end
25
+
26
+ Stub = Service.rpc_stub_class
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path('generated', __dir__))
4
+
5
+ require 'grpc'
6
+
7
+ require_relative 'generated/secret_detection_pb'
8
+ require_relative 'generated/secret_detection_services_pb'
9
+
10
+ require_relative '../core'
11
+ require_relative '../../../../config/log'
12
+
13
+ # StreamEnumerator is used for Bi-directional streaming
14
+ # of requests by returning stream of responses.
15
+ class StreamEnumerator
16
+ def initialize(requests, action)
17
+ @requests = requests
18
+ @request_action = action
19
+ end
20
+
21
+ def each_item
22
+ return enum_for(:each_item) unless block_given?
23
+
24
+ @requests.each do |req|
25
+ yield @request_action.call(req)
26
+ end
27
+ end
28
+ end
29
+
30
+ module GitLab
31
+ module SecretDetection
32
+ module GRPC
33
+ class ScannerService < Scanner::Service
34
+ include SDLogger
35
+
36
+ # Maximum timeout value that can be given as the input. This guards
37
+ # against the misuse of timeouts.
38
+ MAX_ALLOWED_TIMEOUT_SECONDS = 600
39
+
40
+ ERROR_MESSAGES = {
41
+ invalid_payload_fields: "Payload should not contain empty `id` and `data` fields",
42
+ allowlist_empty_value: "Allowlist entry value cannot be empty",
43
+ allowlist_invalid_type: "Invalid Allowlist entry type",
44
+ invalid_timeout_range: "Timeout value should be > 0 and <= #{MAX_ALLOWED_TIMEOUT_SECONDS} seconds"
45
+ }.freeze
46
+
47
+ # Implementation for /Scan RPC method
48
+ def scan(request, _call)
49
+ scan_request_action(request)
50
+ end
51
+
52
+ # Implementation for /ScanStream RPC method
53
+ def scan_stream(requests, _call)
54
+ request_action = ->(r) { scan_request_action(r) }
55
+ StreamEnumerator.new(requests, request_action).each_item
56
+ end
57
+
58
+ private
59
+
60
+ def scan_request_action(request)
61
+ validate_request(request)
62
+
63
+ payloads = request.payloads.to_a
64
+
65
+ rule_exclusions = []
66
+ allow_values = []
67
+ request.allowlist&.each do |entry|
68
+ case entry.allow_type
69
+ when :ALLOW_RULE_TYPE
70
+ rule_exclusions << entry.value
71
+ when :ALLOW_RAW_VALUE
72
+ allow_values << entry.value
73
+ end
74
+ end
75
+
76
+ result = scanner.secrets_scan(
77
+ payloads,
78
+ rule_exclusions:,
79
+ allow_values:,
80
+ tags: request.tags.to_a,
81
+ timeout: request.timeout_secs,
82
+ payload_timeout: request.payload_timeout_secs
83
+ )
84
+
85
+ findings = result.results&.map do |finding|
86
+ GitLab::SecretDetection::GRPC::ScanResponse::Finding.new(**finding.to_h)
87
+ end
88
+
89
+ GitLab::SecretDetection::GRPC::ScanResponse.new(
90
+ results: findings,
91
+ status: result.status
92
+ )
93
+ end
94
+
95
+ def scanner
96
+ @scanner ||= GitLab::SecretDetection::Core::Scanner.new(rules:, logger:)
97
+ end
98
+
99
+ def rules
100
+ GitLab::SecretDetection::Core::Ruleset.new.rules
101
+ end
102
+
103
+ # validates grpc request body
104
+ def validate_request(request)
105
+ # check for non-blank values and allowed types
106
+ request.allowlist&.each do |entry|
107
+ if entry.value.empty?
108
+ raise ::GRPC::InvalidArgument.new(ERROR_MESSAGES[:allowlist_empty_value],
109
+ { field: "allowlist.value" })
110
+ end
111
+ end
112
+
113
+ unless valid_timeout_range?(request.timeout_secs)
114
+ raise ::GRPC::InvalidArgument.new(ERROR_MESSAGES[:invalid_timeout_range],
115
+ { field: "timeout_secs" })
116
+ end
117
+
118
+ unless valid_timeout_range?(request.payload_timeout_secs)
119
+ raise ::GRPC::InvalidArgument.new(ERROR_MESSAGES[:invalid_timeout_range],
120
+ { field: "payload_timeout_secs" })
121
+ end
122
+
123
+ # check for required payload fields
124
+ request.payloads.to_a.each_with_index do |payload, index|
125
+ if !payload.respond_to?(:id) || payload.id.empty?
126
+ raise ::GRPC::InvalidArgument.new(
127
+ ERROR_MESSAGES[:invalid_payload_fields],
128
+ { field: "payloads[#{index}].id" }
129
+ )
130
+ end
131
+
132
+ unless payload.respond_to?(:data) # rubocop:disable Style/Next
133
+ raise ::GRPC::InvalidArgument.new(
134
+ ERROR_MESSAGES[:invalid_payload_fields],
135
+ { field: "payloads[#{index}].data" }
136
+ )
137
+ end
138
+ end
139
+ end
140
+
141
+ # checks if the given timeout value is within range
142
+ def valid_timeout_range?(timeout_value)
143
+ timeout_value >= 0 && timeout_value <= MAX_ALLOWED_TIMEOUT_SECONDS
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'grpc/scanner_service'
4
+ require_relative 'grpc/client/grpc_client'
5
+
6
+ module GitLab
7
+ module SecretDetection
8
+ module GRPC
9
+ end
10
+ end
11
+ end
@@ -1,7 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Gitlab
3
+ module GitLab
4
4
  module SecretDetection
5
- VERSION = "0.1.0"
5
+ class Gem
6
+ DEFAULT_VERSION = "0.0.1"
7
+
8
+ SEMVER_REGEX = /^\d+\.\d+\.\d+(?:-[a-zA-Z0-9\-\.]+)?(?:\+[a-zA-Z0-9\-\.]+)?$/
9
+
10
+ def self.get_release_version
11
+ release_version = ENV.fetch("SD_GEM_RELEASE_VERSION", "")
12
+
13
+ if release_version.empty?
14
+ raise LoadError("Missing SD_GEM_RELEASE_VERSION environment variable.") unless local_env?
15
+
16
+ "#{DEFAULT_VERSION}-debug"
17
+ elsif release_version.match?(SEMVER_REGEX)
18
+ release_version
19
+ else
20
+ "#{DEFAULT_VERSION}-#{release_version}"
21
+ end
22
+ end
23
+
24
+ # SD_ENV env var is used to determine which environment the
25
+ # server is running. This var is defined in `.runway/env-<env>.yml` files.
26
+ def self.local_env?
27
+ ENV.fetch('SD_ENV', 'localhost') == 'localhost'
28
+ end
29
+ end
6
30
  end
7
31
  end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "secret_detection/version"
3
+ require_relative 'secret_detection/core'
4
+ require_relative 'secret_detection/grpc'
5
+ require_relative 'secret_detection/version'
4
6
 
5
- module Gitlab
7
+ module GitLab
6
8
  module SecretDetection
7
- class Error < StandardError; end
8
- # Your code goes here...
9
9
  end
10
10
  end
data/lib/gitlab.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'gitlab/secret_detection'
4
+
5
+ module GitLab
6
+ end