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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f9e6f5022798b252c15cce6bcbdc7ae84e9891af3a3552a1c727953868c1a27
|
|
4
|
+
data.tar.gz: e8ad9883653d36633fa58570b80d6da8d351feb6b9da91356f93db97f2d34466
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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-
|
|
13
|
+
date: 2026-08-28 00:00:00.000000000 Z
|
|
14
14
|
dependencies:
|
|
15
15
|
- !ruby/object:Gem::Dependency
|
|
16
16
|
name: grpc
|