utils.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. package detect
  2. import (
  3. // "encoding/json"
  4. "fmt"
  5. "math"
  6. "path/filepath"
  7. "strings"
  8. "github.com/zricethezav/gitleaks/v8/cmd/scm"
  9. "github.com/zricethezav/gitleaks/v8/detect/codec"
  10. "github.com/zricethezav/gitleaks/v8/logging"
  11. "github.com/zricethezav/gitleaks/v8/report"
  12. "github.com/zricethezav/gitleaks/v8/sources"
  13. "github.com/charmbracelet/lipgloss"
  14. )
  15. var linkCleaner = strings.NewReplacer(
  16. " ", "%20",
  17. "%", "%25",
  18. )
  19. func createScmLink(remote *sources.RemoteInfo, finding report.Finding) string {
  20. if remote.Platform == scm.UnknownPlatform ||
  21. remote.Platform == scm.NoPlatform ||
  22. finding.Commit == "" {
  23. return ""
  24. }
  25. // Clean the path.
  26. filePath, _, hasInnerPath := strings.Cut(finding.File, sources.InnerPathSeparator)
  27. filePath = linkCleaner.Replace(filePath)
  28. switch remote.Platform {
  29. case scm.GitHubPlatform:
  30. link := fmt.Sprintf("%s/blob/%s/%s", remote.Url, finding.Commit, filePath)
  31. if hasInnerPath {
  32. return link
  33. }
  34. ext := strings.ToLower(filepath.Ext(filePath))
  35. if ext == ".ipynb" || ext == ".md" {
  36. link += "?plain=1"
  37. }
  38. if finding.StartLine != 0 {
  39. link += fmt.Sprintf("#L%d", finding.StartLine)
  40. }
  41. if finding.EndLine != finding.StartLine {
  42. link += fmt.Sprintf("-L%d", finding.EndLine)
  43. }
  44. return link
  45. case scm.GitLabPlatform:
  46. link := fmt.Sprintf("%s/blob/%s/%s", remote.Url, finding.Commit, filePath)
  47. if hasInnerPath {
  48. return link
  49. }
  50. if finding.StartLine != 0 {
  51. link += fmt.Sprintf("#L%d", finding.StartLine)
  52. }
  53. if finding.EndLine != finding.StartLine {
  54. link += fmt.Sprintf("-%d", finding.EndLine)
  55. }
  56. return link
  57. case scm.AzureDevOpsPlatform:
  58. link := fmt.Sprintf("%s/commit/%s?path=/%s", remote.Url, finding.Commit, filePath)
  59. // Add line information if applicable
  60. if hasInnerPath {
  61. return link
  62. }
  63. if finding.StartLine != 0 {
  64. link += fmt.Sprintf("&line=%d", finding.StartLine)
  65. }
  66. if finding.EndLine != finding.StartLine {
  67. link += fmt.Sprintf("&lineEnd=%d", finding.EndLine)
  68. }
  69. // This is a bit dirty, but Azure DevOps does not highlight the line when the lineStartColumn and lineEndColumn are not provided
  70. link += "&lineStartColumn=1&lineEndColumn=10000000&type=2&lineStyle=plain&_a=files"
  71. return link
  72. case scm.GiteaPlatform:
  73. link := fmt.Sprintf("%s/src/commit/%s/%s", remote.Url, finding.Commit, filePath)
  74. if hasInnerPath {
  75. return link
  76. }
  77. ext := strings.ToLower(filepath.Ext(filePath))
  78. if ext == ".ipynb" || ext == ".md" {
  79. link += "?display=source"
  80. }
  81. if finding.StartLine != 0 {
  82. link += fmt.Sprintf("#L%d", finding.StartLine)
  83. }
  84. if finding.EndLine != finding.StartLine {
  85. link += fmt.Sprintf("-L%d", finding.EndLine)
  86. }
  87. return link
  88. case scm.BitbucketPlatform:
  89. link := fmt.Sprintf("%s/src/%s/%s", remote.Url, finding.Commit, filePath)
  90. if hasInnerPath {
  91. return link
  92. }
  93. if finding.StartLine != 0 {
  94. link += fmt.Sprintf("#lines-%d", finding.StartLine)
  95. }
  96. if finding.EndLine != finding.StartLine {
  97. link += fmt.Sprintf(":%d", finding.EndLine)
  98. }
  99. return link
  100. default:
  101. // This should never happen.
  102. return ""
  103. }
  104. }
  105. // shannonEntropy calculates the entropy of data using the formula defined here:
  106. // https://en.wiktionary.org/wiki/Shannon_entropy
  107. // Another way to think about what this is doing is calculating the number of bits
  108. // needed to on average encode the data. So, the higher the entropy, the more random the data, the
  109. // more bits needed to encode that data.
  110. func shannonEntropy(data string) (entropy float64) {
  111. if data == "" {
  112. return 0
  113. }
  114. charCounts := make(map[rune]int)
  115. for _, char := range data {
  116. charCounts[char]++
  117. }
  118. invLength := 1.0 / float64(len(data))
  119. for _, count := range charCounts {
  120. freq := float64(count) * invLength
  121. entropy -= freq * math.Log2(freq)
  122. }
  123. return entropy
  124. }
  125. // filter will dedupe and redact findings
  126. func filter(findings []report.Finding, redact uint) []report.Finding {
  127. var retFindings []report.Finding
  128. decoder := codec.NewDecoder()
  129. encodedSegments := []*codec.EncodedSegment{}
  130. for _, f := range findings {
  131. include := true
  132. if strings.Contains(strings.ToLower(f.RuleID), "generic") {
  133. for _, fPrime := range findings {
  134. // TODO also check if a decoded secret == the generic secret. If it does, skip the generic secret
  135. isDecoded := false
  136. decodedSecret := ""
  137. for _, t := range fPrime.Tags {
  138. if strings.Contains(t, "decoded") {
  139. isDecoded = true
  140. }
  141. }
  142. if isDecoded {
  143. decodedSecret, _ = decoder.Decode(f.Secret, encodedSegments)
  144. decodedSecret = strings.TrimSuffix(decodedSecret, "\n")
  145. }
  146. if f.StartLine == fPrime.StartLine &&
  147. f.Commit == fPrime.Commit &&
  148. f.RuleID != fPrime.RuleID &&
  149. (strings.Contains(fPrime.Secret, f.Secret) || decodedSecret == fPrime.Secret) &&
  150. !strings.Contains(strings.ToLower(fPrime.RuleID), "generic") {
  151. genericMatch := strings.ReplaceAll(f.Match, f.Secret, "REDACTED")
  152. betterMatch := strings.ReplaceAll(fPrime.Match, fPrime.Secret, "REDACTED")
  153. logging.Trace().Msgf("skipping %s finding (%s), %s rule takes precedence (%s)", f.RuleID, genericMatch, fPrime.RuleID, betterMatch)
  154. include = false
  155. break
  156. }
  157. }
  158. }
  159. if redact > 0 {
  160. f.Redact(redact)
  161. }
  162. if include {
  163. retFindings = append(retFindings, f)
  164. }
  165. }
  166. return retFindings
  167. }
  168. func printFinding(f report.Finding, noColor bool) {
  169. // trim all whitespace and tabs
  170. f.Line = strings.TrimSpace(f.Line)
  171. f.Secret = strings.TrimSpace(f.Secret)
  172. f.Match = strings.TrimSpace(f.Match)
  173. isFileMatch := strings.HasPrefix(f.Match, "file detected:")
  174. skipColor := noColor
  175. finding := ""
  176. var secret lipgloss.Style
  177. // Matches from filenames do not have a |line| or |secret|
  178. if !isFileMatch {
  179. matchInLineIDX := strings.Index(f.Line, f.Match)
  180. secretInMatchIdx := strings.Index(f.Match, f.Secret)
  181. skipColor = false
  182. if matchInLineIDX == -1 || noColor {
  183. skipColor = true
  184. matchInLineIDX = 0
  185. }
  186. start := f.Line[0:matchInLineIDX]
  187. startMatchIdx := 0
  188. if matchInLineIDX > 20 {
  189. startMatchIdx = matchInLineIDX - 20
  190. start = "..." + f.Line[startMatchIdx:matchInLineIDX]
  191. }
  192. matchBeginning := lipgloss.NewStyle().SetString(f.Match[0:secretInMatchIdx]).Foreground(lipgloss.Color("#f5d445"))
  193. secret = lipgloss.NewStyle().SetString(f.Secret).
  194. Bold(true).
  195. Italic(true).
  196. Foreground(lipgloss.Color("#f05c07"))
  197. matchEnd := lipgloss.NewStyle().SetString(f.Match[secretInMatchIdx+len(f.Secret):]).Foreground(lipgloss.Color("#f5d445"))
  198. lineEndIdx := matchInLineIDX + len(f.Match)
  199. if len(f.Line)-1 <= lineEndIdx {
  200. lineEndIdx = len(f.Line)
  201. }
  202. lineEnd := f.Line[lineEndIdx:]
  203. if len(f.Secret) > 100 {
  204. secret = lipgloss.NewStyle().SetString(f.Secret[0:100] + "...").
  205. Bold(true).
  206. Italic(true).
  207. Foreground(lipgloss.Color("#f05c07"))
  208. }
  209. if len(lineEnd) > 20 {
  210. lineEnd = lineEnd[0:20] + "..."
  211. }
  212. finding = fmt.Sprintf("%s%s%s%s%s\n", strings.TrimPrefix(strings.TrimLeft(start, " "), "\n"), matchBeginning, secret, matchEnd, lineEnd)
  213. }
  214. if skipColor || isFileMatch {
  215. fmt.Printf("%-12s %s\n", "Finding:", f.Match)
  216. fmt.Printf("%-12s %s\n", "Secret:", f.Secret)
  217. } else {
  218. fmt.Printf("%-12s %s", "Finding:", finding)
  219. fmt.Printf("%-12s %s\n", "Secret:", secret)
  220. }
  221. fmt.Printf("%-12s %s\n", "RuleID:", f.RuleID)
  222. fmt.Printf("%-12s %f\n", "Entropy:", f.Entropy)
  223. if f.File == "" {
  224. f.PrintRequiredFindings()
  225. fmt.Println("")
  226. return
  227. }
  228. if len(f.Tags) > 0 {
  229. fmt.Printf("%-12s %s\n", "Tags:", f.Tags)
  230. }
  231. fmt.Printf("%-12s %s\n", "File:", f.File)
  232. fmt.Printf("%-12s %d\n", "Line:", f.StartLine)
  233. if f.Commit == "" {
  234. fmt.Printf("%-12s %s\n", "Fingerprint:", f.Fingerprint)
  235. f.PrintRequiredFindings()
  236. fmt.Println("")
  237. return
  238. }
  239. fmt.Printf("%-12s %s\n", "Commit:", f.Commit)
  240. fmt.Printf("%-12s %s\n", "Author:", f.Author)
  241. fmt.Printf("%-12s %s\n", "Email:", f.Email)
  242. fmt.Printf("%-12s %s\n", "Date:", f.Date)
  243. fmt.Printf("%-12s %s\n", "Fingerprint:", f.Fingerprint)
  244. if f.Link != "" {
  245. fmt.Printf("%-12s %s\n", "Link:", f.Link)
  246. }
  247. f.PrintRequiredFindings()
  248. fmt.Println("")
  249. }
  250. func isIrregularlyCased(word, secret string) bool {
  251. // Find the word in the secret (case-insensitive)
  252. secretLower := strings.ToLower(secret)
  253. wordLower := strings.ToLower(word)
  254. index := strings.Index(secretLower, wordLower)
  255. if index == -1 {
  256. return false
  257. }
  258. // Extract the actual casing from the secret
  259. actualWord := secret[index : index+len(word)]
  260. // Check if it matches conventional casing patterns
  261. return !isConventionallyCased(actualWord)
  262. }
  263. // Helper function to determine if a word follows conventional casing
  264. func isConventionallyCased(word string) bool {
  265. if len(word) == 0 {
  266. return true
  267. }
  268. // Conventional patterns:
  269. // - All lowercase (common)
  270. // - All uppercase (acronyms, constants)
  271. // - Title case (proper nouns)
  272. // - First letter uppercase, rest lowercase
  273. allLower := strings.ToLower(word) == word
  274. allUpper := strings.ToUpper(word) == word
  275. titleCase := strings.Title(strings.ToLower(word)) == word
  276. return allLower || allUpper || titleCase
  277. }