package main // secrets.go — a last look at what `push` is about to commit. // // `push` runs `git add --all .`, so anything lying in the project — an .env, a // stray key file, a token pasted into a config — is committed and pushed, and // with `mirror = true` it reaches a *public* server in the same breath. That is // the one action in mgsh that cannot be undone: a deleted server repository can // come back from an archive, a published credential is burnt. // // So the staged diff is scanned for a small set of high-signal patterns before // the commit is made. This is not a complete secret scanner and does not try to // be one; it aims for a high hit rate on the things that actually leak, with // few enough false alarms that the prompt still means something. Turn it off // with `secretscan = off`. import ( "fmt" "regexp" "strconv" "strings" ) // secretHit is one suspicious added line. type secretHit struct { file string lineNo int kind string text string } // secretPattern matches one kind of credential. `certain` patterns are // unmistakable and are reported as they are; the others match a shape that // merely looks like a secret and are filtered through looksLikePlaceholder. type secretPattern struct { kind string re *regexp.Regexp certain bool } var secretPatterns = []secretPattern{ {"private key", regexp.MustCompile(`-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----`), true}, {"GitHub token", regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{20,}`), true}, {"GitLab token", regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{16,}`), true}, {"AWS access key", regexp.MustCompile(`\b(?:AKIA|ASIA)[0-9A-Z]{16}\b`), true}, {"Slack token", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`), true}, {"PyPI token", regexp.MustCompile(`\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{10,}`), true}, {"credential assignment", regexp.MustCompile( `(?i)\b(?:password|passwd|secret|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|token)\b` + `\s*[:=]\s*(?:"([^"\s]{12,})"|'([^'\s]{12,})'|([^\s"';,]{20,}))\s*;?\s*$`), false}, } var ( diffFileRe = regexp.MustCompile(`^\+\+\+ b/(.*)$`) diffHunkRe = regexp.MustCompile(`^@@ -\d+(?:,\d+)? \+(\d+)`) // a value that is plainly a reference or a stand-in, not a credential constRefRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) dottedRefRe = regexp.MustCompile(`^[\w-]+(?:\.[\w-]+)+$`) maskedRe = regexp.MustCompile(`^[*x•.]+$`) ) // placeholderWords are the values people write when they mean "fill this in". var placeholderWords = map[string]bool{ "changeme": true, "change_me": true, "password": true, "secret": true, "token": true, "your_token": true, "your-token": true, "yourtoken": true, "todo": true, "none": true, "null": true, "example": true, "redacted": true, } // looksLikePlaceholder reports whether a matched value is obviously not a real // credential: a template slot, an environment reference, a constant name, or a // masked stand-in. Documentation and example files are full of these, and every // one of them that reaches the prompt makes the prompt worth less. func looksLikePlaceholder(v string) bool { v = strings.Trim(v, `"'`) if v == "" { return true } if strings.ContainsAny(v, "<>${}()") { // , ${VAR}, $(cmd), {{ tpl }} return true } if maskedRe.MatchString(v) || constRefRe.MatchString(v) || dottedRefRe.MatchString(v) { return true } if placeholderWords[strings.ToLower(v)] { return true } // a value made of one repeated character carries no information if strings.Count(v, string(v[0])) == len(v) { return true } return false } // scanDiff finds suspicious added lines in a unified diff. Only added lines are // examined: removing a secret is what we want people to do. func scanDiff(diff string) []secretHit { var hits []secretHit file := "" lineNo := 0 for _, ln := range strings.Split(diff, "\n") { switch { case strings.HasPrefix(ln, "+++ "): file = "" if m := diffFileRe.FindStringSubmatch(ln); m != nil { file = m[1] } continue case strings.HasPrefix(ln, "@@"): if m := diffHunkRe.FindStringSubmatch(ln); m != nil { lineNo, _ = strconv.Atoi(m[1]) } continue case strings.HasPrefix(ln, "---") || strings.HasPrefix(ln, "diff ") || strings.HasPrefix(ln, "index ") || strings.HasPrefix(ln, "new file") || strings.HasPrefix(ln, "deleted file") || strings.HasPrefix(ln, "similarity "): continue case strings.HasPrefix(ln, "-"): continue // removed line: not our problem case !strings.HasPrefix(ln, "+"): lineNo++ // context line continue } text := ln[1:] if kind := matchSecret(text); kind != "" { hits = append(hits, secretHit{file: file, lineNo: lineNo, kind: kind, text: text}) } lineNo++ } return hits } // allowMarker suppresses the check for one line. Any scanner needs a per-line // escape: a project will eventually hold something credential-shaped on // purpose, and switching the whole check off for that is far too blunt. const allowMarker = "mgsh:allow" // matchSecret returns the kind of credential a line appears to contain, or "". func matchSecret(text string) string { if strings.Contains(text, allowMarker) { return "" } for _, p := range secretPatterns { m := p.re.FindStringSubmatch(text) if m == nil { continue } if p.certain { return p.kind } // the first non-empty capture group is the value that was assigned value := "" for _, g := range m[1:] { if g != "" { value = g break } } if !looksLikePlaceholder(value) { return p.kind } } return "" } // secretScanEnabled reports whether the scan runs. It is on unless explicitly // switched off, so a typo in the setting leaves the safety net in place. func secretScanEnabled() bool { return !falsy(cfg.SecretScan) } // secretsApproved scans what `push` has staged. With nothing suspicious found // it returns true silently; otherwise it shows the findings and asks. Returns // false when the push should stop. func secretsApproved(dir string) bool { if !secretScanEnabled() { return true } diff, err := gitCapture(dir, "diff", "--cached", "-U0", "--no-color") if err != nil { return true // nothing staged, or no HEAD yet: not our call to block } hits := scanDiff(diff) if len(hits) == 0 { return true } fmt.Println(col(cRed, fmt.Sprintf("%d possible credential(s) in what is about to be committed:", len(hits)))) for _, h := range hits { where := h.file if h.lineNo > 0 { where += ":" + strconv.Itoa(h.lineNo) } fmt.Printf(" %s %s\n %s\n", col(cYellow, where), col(cDark, h.kind), col(cRed, ellipsis(strings.TrimSpace(h.text), 100))) } fmt.Println(col(cDark, " (set 'secretscan = off' to skip this check)")) return yesno("push anyway?", false) } // ellipsis shortens s to at most n characters. func ellipsis(s string, n int) string { if len(s) <= n { return s } return s[:n-1] + "…" }