detect.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. package detect
  2. import (
  3. "bufio"
  4. "context"
  5. "fmt"
  6. "os"
  7. "runtime"
  8. "strings"
  9. "sync"
  10. "sync/atomic"
  11. "time"
  12. "github.com/zricethezav/gitleaks/v8/config"
  13. "github.com/zricethezav/gitleaks/v8/detect/codec"
  14. "github.com/zricethezav/gitleaks/v8/logging"
  15. "github.com/zricethezav/gitleaks/v8/regexp"
  16. "github.com/zricethezav/gitleaks/v8/report"
  17. ahocorasick "github.com/BobuSumisu/aho-corasick"
  18. "github.com/fatih/semgroup"
  19. "github.com/rs/zerolog"
  20. "github.com/spf13/viper"
  21. "golang.org/x/exp/maps"
  22. )
  23. const (
  24. gitleaksAllowSignature = "gitleaks:allow"
  25. chunkSize = 100 * 1_000 // 100kb
  26. // SlowWarningThreshold is the amount of time to wait before logging that a file is slow.
  27. // This is useful for identifying problematic files and tuning the allowlist.
  28. SlowWarningThreshold = 5 * time.Second
  29. )
  30. var (
  31. newLineRegexp = regexp.MustCompile("\n")
  32. isWindows = runtime.GOOS == "windows"
  33. tmpDir string
  34. )
  35. func init() {
  36. var err error
  37. tmpDir, err = os.MkdirTemp("", "gitleaks-*")
  38. if err != nil {
  39. logging.Fatal().Err(err).Msg("failed to create temp dir for archive extraction")
  40. }
  41. }
  42. // Detector is the main detector struct
  43. type Detector struct {
  44. // Config is the configuration for the detector
  45. Config config.Config
  46. // Redact is a flag to redact findings. This is exported
  47. // so users using gitleaks as a library can set this flag
  48. // without calling `detector.Start(cmd *cobra.Command)`
  49. Redact uint
  50. // verbose is a flag to print findings
  51. Verbose bool
  52. // MaxDecodeDepths limits how many recursive decoding passes are allowed
  53. MaxDecodeDepth int
  54. // files larger than this will be skipped
  55. MaxTargetMegaBytes int
  56. // followSymlinks is a flag to enable scanning symlink files
  57. FollowSymlinks bool
  58. // NoColor is a flag to disable color output
  59. NoColor bool
  60. // IgnoreGitleaksAllow is a flag to ignore gitleaks:allow comments.
  61. IgnoreGitleaksAllow bool
  62. // commitMap is used to keep track of commits that have been scanned.
  63. // This is only used for logging purposes and git scans.
  64. commitMap map[string]bool
  65. // findingMutex is to prevent concurrent access to the
  66. // findings slice when adding findings.
  67. findingMutex *sync.Mutex
  68. // findings is a slice of report.Findings. This is the result
  69. // of the detector's scan which can then be used to generate a
  70. // report.
  71. findings []report.Finding
  72. // prefilter is a ahocorasick struct used for doing efficient string
  73. // matching given a set of words (keywords from the rules in the config)
  74. prefilter ahocorasick.Trie
  75. // a list of known findings that should be ignored
  76. baseline []report.Finding
  77. // path to baseline
  78. baselinePath string
  79. // gitleaksIgnore
  80. gitleaksIgnore map[string]struct{}
  81. // Sema (https://github.com/fatih/semgroup) controls the concurrency
  82. Sema *semgroup.Group
  83. // report-related settings.
  84. ReportPath string
  85. Reporter report.Reporter
  86. TotalBytes atomic.Uint64
  87. }
  88. func (d *Detector) Cleanup() {
  89. if tmpDir != "" {
  90. if err := os.RemoveAll(tmpDir); err != nil {
  91. logging.Warn().Err(err).Msg("failed to remove temp dir for archive extraction")
  92. }
  93. }
  94. }
  95. // NewDetector creates a new detector with the given config
  96. func NewDetector(cfg config.Config) *Detector {
  97. return &Detector{
  98. commitMap: make(map[string]bool),
  99. gitleaksIgnore: make(map[string]struct{}),
  100. findingMutex: &sync.Mutex{},
  101. findings: make([]report.Finding, 0),
  102. Config: cfg,
  103. prefilter: *ahocorasick.NewTrieBuilder().AddStrings(maps.Keys(cfg.Keywords)).Build(),
  104. Sema: semgroup.NewGroup(context.Background(), 40),
  105. }
  106. }
  107. // NewDetectorDefaultConfig creates a new detector with the default config
  108. func NewDetectorDefaultConfig() (*Detector, error) {
  109. viper.SetConfigType("toml")
  110. err := viper.ReadConfig(strings.NewReader(config.DefaultConfig))
  111. if err != nil {
  112. return nil, err
  113. }
  114. var vc config.ViperConfig
  115. err = viper.Unmarshal(&vc)
  116. if err != nil {
  117. return nil, err
  118. }
  119. cfg, err := vc.Translate()
  120. if err != nil {
  121. return nil, err
  122. }
  123. return NewDetector(cfg), nil
  124. }
  125. func (d *Detector) AddGitleaksIgnore(gitleaksIgnorePath string) error {
  126. logging.Debug().Msgf("found .gitleaksignore file: %s", gitleaksIgnorePath)
  127. file, err := os.Open(gitleaksIgnorePath)
  128. if err != nil {
  129. return err
  130. }
  131. defer func() {
  132. // https://github.com/securego/gosec/issues/512
  133. if err := file.Close(); err != nil {
  134. logging.Warn().Msgf("Error closing .gitleaksignore file: %s\n", err)
  135. }
  136. }()
  137. scanner := bufio.NewScanner(file)
  138. replacer := strings.NewReplacer("\\", "/")
  139. for scanner.Scan() {
  140. line := strings.TrimSpace(scanner.Text())
  141. // Skip lines that start with a comment
  142. if line == "" || strings.HasPrefix(line, "#") {
  143. continue
  144. }
  145. // Normalize the path.
  146. // TODO: Make this a breaking change in v9.
  147. s := strings.Split(line, ":")
  148. switch len(s) {
  149. case 3:
  150. // Global fingerprint.
  151. // `file:rule-id:start-line`
  152. s[0] = replacer.Replace(s[0])
  153. case 4:
  154. // Commit fingerprint.
  155. // `commit:file:rule-id:start-line`
  156. s[1] = replacer.Replace(s[1])
  157. default:
  158. logging.Warn().Str("fingerprint", line).Msg("Invalid .gitleaksignore entry")
  159. }
  160. d.gitleaksIgnore[strings.Join(s, ":")] = struct{}{}
  161. }
  162. return nil
  163. }
  164. // DetectBytes scans the given bytes and returns a list of findings
  165. func (d *Detector) DetectBytes(content []byte) []report.Finding {
  166. return d.DetectString(string(content))
  167. }
  168. // DetectString scans the given string and returns a list of findings
  169. func (d *Detector) DetectString(content string) []report.Finding {
  170. return d.Detect(Fragment{
  171. Raw: content,
  172. })
  173. }
  174. // Detect scans the given fragment and returns a list of findings
  175. func (d *Detector) Detect(fragment Fragment) []report.Finding {
  176. if fragment.Bytes == nil {
  177. d.TotalBytes.Add(uint64(len(fragment.Raw)))
  178. }
  179. d.TotalBytes.Add(uint64(len(fragment.Bytes)))
  180. var (
  181. findings []report.Finding
  182. logger = func() zerolog.Logger {
  183. l := logging.With().Str("path", fragment.FilePath)
  184. if fragment.CommitSHA != "" {
  185. l = l.Str("commit", fragment.CommitSHA)
  186. }
  187. return l.Logger()
  188. }()
  189. )
  190. // check if filepath is allowed
  191. if fragment.FilePath != "" {
  192. // is the path our config or baseline file?
  193. if fragment.FilePath == d.Config.Path || (d.baselinePath != "" && fragment.FilePath == d.baselinePath) {
  194. logging.Trace().Msg("skipping file: matches config or baseline path")
  195. return findings
  196. }
  197. }
  198. // check if commit or filepath is allowed.
  199. if isAllowed, event := checkCommitOrPathAllowed(logger, fragment, d.Config.Allowlists); isAllowed {
  200. event.Msg("skipping file: global allowlist")
  201. return findings
  202. }
  203. // add newline indices for location calculation in detectRule
  204. fragment.newlineIndices = newLineRegexp.FindAllStringIndex(fragment.Raw, -1)
  205. // setup variables to handle different decoding passes
  206. currentRaw := fragment.Raw
  207. encodedSegments := []*codec.EncodedSegment{}
  208. currentDecodeDepth := 0
  209. decoder := codec.NewDecoder()
  210. for {
  211. // build keyword map for prefiltering rules
  212. keywords := make(map[string]bool)
  213. normalizedRaw := strings.ToLower(currentRaw)
  214. matches := d.prefilter.MatchString(normalizedRaw)
  215. for _, m := range matches {
  216. keywords[normalizedRaw[m.Pos():int(m.Pos())+len(m.Match())]] = true
  217. }
  218. for _, rule := range d.Config.Rules {
  219. if len(rule.Keywords) == 0 {
  220. // if no keywords are associated with the rule always scan the
  221. // fragment using the rule
  222. findings = append(findings, d.detectRule(fragment, currentRaw, rule, encodedSegments)...)
  223. continue
  224. }
  225. // check if keywords are in the fragment
  226. for _, k := range rule.Keywords {
  227. if _, ok := keywords[strings.ToLower(k)]; ok {
  228. findings = append(findings, d.detectRule(fragment, currentRaw, rule, encodedSegments)...)
  229. break
  230. }
  231. }
  232. }
  233. // increment the depth by 1 as we start our decoding pass
  234. currentDecodeDepth++
  235. // stop the loop if we've hit our max decoding depth
  236. if currentDecodeDepth > d.MaxDecodeDepth {
  237. break
  238. }
  239. // decode the currentRaw for the next pass
  240. currentRaw, encodedSegments = decoder.Decode(currentRaw, encodedSegments)
  241. // stop the loop when there's nothing else to decode
  242. if len(encodedSegments) == 0 {
  243. break
  244. }
  245. }
  246. return filter(findings, d.Redact)
  247. }
  248. // detectRule scans the given fragment for the given rule and returns a list of findings
  249. func (d *Detector) detectRule(fragment Fragment, currentRaw string, r config.Rule, encodedSegments []*codec.EncodedSegment) []report.Finding {
  250. var (
  251. findings []report.Finding
  252. logger = func() zerolog.Logger {
  253. l := logging.With().Str("rule-id", r.RuleID).Str("path", fragment.FilePath)
  254. if fragment.CommitSHA != "" {
  255. l = l.Str("commit", fragment.CommitSHA)
  256. }
  257. return l.Logger()
  258. }()
  259. )
  260. // check if commit or file is allowed for this rule.
  261. if isAllowed, event := checkCommitOrPathAllowed(logger, fragment, r.Allowlists); isAllowed {
  262. event.Msg("skipping file: rule allowlist")
  263. return findings
  264. }
  265. if r.Path != nil {
  266. if r.Regex == nil && len(encodedSegments) == 0 {
  267. // Path _only_ rule
  268. if r.Path.MatchString(fragment.FilePath) || (fragment.WindowsFilePath != "" && r.Path.MatchString(fragment.WindowsFilePath)) {
  269. finding := report.Finding{
  270. RuleID: r.RuleID,
  271. Description: r.Description,
  272. File: fragment.FilePath,
  273. SymlinkFile: fragment.SymlinkFile,
  274. Match: fmt.Sprintf("file detected: %s", fragment.FilePath),
  275. Tags: r.Tags,
  276. }
  277. return append(findings, finding)
  278. }
  279. } else {
  280. // if path is set _and_ a regex is set, then we need to check both
  281. // so if the path does not match, then we should return early and not
  282. // consider the regex
  283. if !(r.Path.MatchString(fragment.FilePath) || (fragment.WindowsFilePath != "" && r.Path.MatchString(fragment.WindowsFilePath))) {
  284. return findings
  285. }
  286. }
  287. }
  288. // if path only rule, skip content checks
  289. if r.Regex == nil {
  290. return findings
  291. }
  292. // if flag configure and raw data size bigger then the flag
  293. if d.MaxTargetMegaBytes > 0 {
  294. rawLength := len(currentRaw) / 1000000
  295. if rawLength > d.MaxTargetMegaBytes {
  296. logger.Debug().
  297. Int("size", rawLength).
  298. Int("max-size", d.MaxTargetMegaBytes).
  299. Msg("skipping fragment: size")
  300. return findings
  301. }
  302. }
  303. // use currentRaw instead of fragment.Raw since this represents the current
  304. // decoding pass on the text
  305. for _, matchIndex := range r.Regex.FindAllStringIndex(currentRaw, -1) {
  306. // Extract secret from match
  307. secret := strings.Trim(currentRaw[matchIndex[0]:matchIndex[1]], "\n")
  308. // For any meta data from decoding
  309. var metaTags []string
  310. currentLine := ""
  311. // Check if the decoded portions of the segment overlap with the match
  312. // to see if its potentially a new match
  313. if len(encodedSegments) > 0 {
  314. segments := codec.SegmentsWithDecodedOverlap(encodedSegments, matchIndex[0], matchIndex[1])
  315. if len(segments) == 0 {
  316. // This item has already been added to a finding
  317. continue
  318. }
  319. matchIndex = codec.AdjustMatchIndex(segments, matchIndex)
  320. metaTags = append(metaTags, codec.Tags(segments)...)
  321. currentLine = codec.CurrentLine(segments, currentRaw)
  322. } else {
  323. // Fixes: https://github.com/gitleaks/gitleaks/issues/1352
  324. // removes the incorrectly following line that was detected by regex expression '\n'
  325. matchIndex[1] = matchIndex[0] + len(secret)
  326. }
  327. // determine location of match. Note that the location
  328. // in the finding will be the line/column numbers of the _match_
  329. // not the _secret_, which will be different if the secretGroup
  330. // value is set for this rule
  331. loc := location(fragment, matchIndex)
  332. if matchIndex[1] > loc.endLineIndex {
  333. loc.endLineIndex = matchIndex[1]
  334. }
  335. finding := report.Finding{
  336. RuleID: r.RuleID,
  337. Description: r.Description,
  338. StartLine: loc.startLine,
  339. EndLine: loc.endLine,
  340. StartColumn: loc.startColumn,
  341. EndColumn: loc.endColumn,
  342. Line: fragment.Raw[loc.startLineIndex:loc.endLineIndex],
  343. Match: secret,
  344. Secret: secret,
  345. File: fragment.FilePath,
  346. SymlinkFile: fragment.SymlinkFile,
  347. Tags: append(r.Tags, metaTags...),
  348. }
  349. if !d.IgnoreGitleaksAllow && strings.Contains(finding.Line, gitleaksAllowSignature) {
  350. logger.Trace().
  351. Str("finding", finding.Secret).
  352. Msg("skipping finding: 'gitleaks:allow' signature")
  353. continue
  354. }
  355. if currentLine == "" {
  356. currentLine = finding.Line
  357. }
  358. // Set the value of |secret|, if the pattern contains at least one capture group.
  359. // (The first element is the full match, hence we check >= 2.)
  360. groups := r.Regex.FindStringSubmatch(finding.Secret)
  361. if len(groups) >= 2 {
  362. if r.SecretGroup > 0 {
  363. if len(groups) <= r.SecretGroup {
  364. // Config validation should prevent this
  365. continue
  366. }
  367. finding.Secret = groups[r.SecretGroup]
  368. } else {
  369. // If |secretGroup| is not set, we will use the first suitable capture group.
  370. for _, s := range groups[1:] {
  371. if len(s) > 0 {
  372. finding.Secret = s
  373. break
  374. }
  375. }
  376. }
  377. }
  378. // check entropy
  379. entropy := shannonEntropy(finding.Secret)
  380. finding.Entropy = float32(entropy)
  381. if r.Entropy != 0.0 {
  382. // entropy is too low, skip this finding
  383. if entropy <= r.Entropy {
  384. logger.Trace().
  385. Str("finding", finding.Secret).
  386. Float32("entropy", finding.Entropy).
  387. Msg("skipping finding: low entropy")
  388. continue
  389. }
  390. }
  391. // check if the result matches any of the global allowlists.
  392. if isAllowed, event := checkFindingAllowed(logger, finding, fragment, currentLine, d.Config.Allowlists); isAllowed {
  393. event.Msg("skipping finding: global allowlist")
  394. continue
  395. }
  396. // check if the result matches any of the rule allowlists.
  397. if isAllowed, event := checkFindingAllowed(logger, finding, fragment, currentLine, r.Allowlists); isAllowed {
  398. event.Msg("skipping finding: rule allowlist")
  399. continue
  400. }
  401. findings = append(findings, finding)
  402. }
  403. return findings
  404. }
  405. // AddFinding synchronously adds a finding to the findings slice
  406. func (d *Detector) AddFinding(finding report.Finding) {
  407. globalFingerprint := fmt.Sprintf("%s:%s:%d", finding.File, finding.RuleID, finding.StartLine)
  408. if finding.Commit != "" {
  409. finding.Fingerprint = fmt.Sprintf("%s:%s:%s:%d", finding.Commit, finding.File, finding.RuleID, finding.StartLine)
  410. } else {
  411. finding.Fingerprint = globalFingerprint
  412. }
  413. // check if we should ignore this finding
  414. logger := logging.With().Str("finding", finding.Secret).Logger()
  415. if _, ok := d.gitleaksIgnore[globalFingerprint]; ok {
  416. logger.Debug().
  417. Str("fingerprint", globalFingerprint).
  418. Msg("skipping finding: global fingerprint")
  419. return
  420. } else if finding.Commit != "" {
  421. // Awkward nested if because I'm not sure how to chain these two conditions.
  422. if _, ok := d.gitleaksIgnore[finding.Fingerprint]; ok {
  423. logger.Debug().
  424. Str("fingerprint", finding.Fingerprint).
  425. Msgf("skipping finding: fingerprint")
  426. return
  427. }
  428. }
  429. if d.baseline != nil && !IsNew(finding, d.Redact, d.baseline) {
  430. logger.Debug().
  431. Str("fingerprint", finding.Fingerprint).
  432. Msgf("skipping finding: baseline")
  433. return
  434. }
  435. d.findingMutex.Lock()
  436. d.findings = append(d.findings, finding)
  437. if d.Verbose {
  438. printFinding(finding, d.NoColor)
  439. }
  440. d.findingMutex.Unlock()
  441. }
  442. // Findings returns the findings added to the detector
  443. func (d *Detector) Findings() []report.Finding {
  444. return d.findings
  445. }
  446. // AddCommit synchronously adds a commit to the commit slice
  447. func (d *Detector) addCommit(commit string) {
  448. d.commitMap[commit] = true
  449. }
  450. // checkCommitOrPathAllowed evaluates |fragment| against all provided |allowlists|.
  451. //
  452. // If the match condition is "OR", only commit and path are checked.
  453. // Otherwise, if regexes or stopwords are defined this will fail.
  454. func checkCommitOrPathAllowed(
  455. logger zerolog.Logger,
  456. fragment Fragment,
  457. allowlists []*config.Allowlist,
  458. ) (bool, *zerolog.Event) {
  459. if fragment.FilePath == "" && fragment.CommitSHA == "" {
  460. return false, nil
  461. }
  462. for _, a := range allowlists {
  463. var (
  464. isAllowed bool
  465. allowlistChecks []bool
  466. commitAllowed, _ = a.CommitAllowed(fragment.CommitSHA)
  467. pathAllowed = a.PathAllowed(fragment.FilePath) || (fragment.WindowsFilePath != "" && a.PathAllowed(fragment.WindowsFilePath))
  468. )
  469. // If the condition is "AND" we need to check all conditions.
  470. if a.MatchCondition == config.AllowlistMatchAnd {
  471. if len(a.Commits) > 0 {
  472. allowlistChecks = append(allowlistChecks, commitAllowed)
  473. }
  474. if len(a.Paths) > 0 {
  475. allowlistChecks = append(allowlistChecks, pathAllowed)
  476. }
  477. // These will be checked later.
  478. if len(a.Regexes) > 0 {
  479. continue
  480. }
  481. if len(a.StopWords) > 0 {
  482. continue
  483. }
  484. isAllowed = allTrue(allowlistChecks)
  485. } else {
  486. isAllowed = commitAllowed || pathAllowed
  487. }
  488. if isAllowed {
  489. event := logger.Trace().Str("condition", a.MatchCondition.String())
  490. if commitAllowed {
  491. event.Bool("allowed-commit", commitAllowed)
  492. }
  493. if pathAllowed {
  494. event.Bool("allowed-path", pathAllowed)
  495. }
  496. return true, event
  497. }
  498. }
  499. return false, nil
  500. }
  501. // checkFindingAllowed evaluates |finding| against all provided |allowlists|.
  502. //
  503. // If the match condition is "OR", only regex and stopwords are run. (Commit and path should be handled separately).
  504. // Otherwise, all conditions are checked.
  505. //
  506. // TODO: The method signature is awkward. I can't think of a better way to log helpful info.
  507. func checkFindingAllowed(
  508. logger zerolog.Logger,
  509. finding report.Finding,
  510. fragment Fragment,
  511. currentLine string,
  512. allowlists []*config.Allowlist,
  513. ) (bool, *zerolog.Event) {
  514. for _, a := range allowlists {
  515. allowlistTarget := finding.Secret
  516. switch a.RegexTarget {
  517. case "match":
  518. allowlistTarget = finding.Match
  519. case "line":
  520. allowlistTarget = currentLine
  521. }
  522. var (
  523. checks []bool
  524. isAllowed bool
  525. commitAllowed bool
  526. commit string
  527. pathAllowed bool
  528. regexAllowed = a.RegexAllowed(allowlistTarget)
  529. containsStopword, word = a.ContainsStopWord(finding.Secret)
  530. )
  531. // If the condition is "AND" we need to check all conditions.
  532. if a.MatchCondition == config.AllowlistMatchAnd {
  533. // Determine applicable checks.
  534. if len(a.Commits) > 0 {
  535. commitAllowed, commit = a.CommitAllowed(fragment.CommitSHA)
  536. checks = append(checks, commitAllowed)
  537. }
  538. if len(a.Paths) > 0 {
  539. pathAllowed = a.PathAllowed(fragment.FilePath) || (fragment.WindowsFilePath != "" && a.PathAllowed(fragment.WindowsFilePath))
  540. checks = append(checks, pathAllowed)
  541. }
  542. if len(a.Regexes) > 0 {
  543. checks = append(checks, regexAllowed)
  544. }
  545. if len(a.StopWords) > 0 {
  546. checks = append(checks, containsStopword)
  547. }
  548. isAllowed = allTrue(checks)
  549. } else {
  550. isAllowed = regexAllowed || containsStopword
  551. }
  552. if isAllowed {
  553. event := logger.Trace().
  554. Str("finding", finding.Secret).
  555. Str("condition", a.MatchCondition.String())
  556. if commitAllowed {
  557. event.Str("allowed-commit", commit)
  558. }
  559. if pathAllowed {
  560. event.Bool("allowed-path", pathAllowed)
  561. }
  562. if regexAllowed {
  563. event.Bool("allowed-regex", regexAllowed)
  564. }
  565. if containsStopword {
  566. event.Str("allowed-stopword", word)
  567. }
  568. return true, event
  569. }
  570. }
  571. return false, nil
  572. }
  573. func allTrue(bools []bool) bool {
  574. for _, check := range bools {
  575. if !check {
  576. return false
  577. }
  578. }
  579. return true
  580. }