Check the staged diff for credentials before push commits
`push` runs `git add --all .`, so anything lying in the project gets committed, and with `mirror = true` it reaches a public server in the same breath. It is the one action in mgsh that cannot be undone: a deleted server repository comes back from an archive, a published credential does not. The staged diff is now scanned before the commit is made -- private keys, GitHub/GitLab/Slack/AWS/PyPI tokens, and credential-shaped assignments -- and a hit is shown with file and line before asking whether to continue. Declining leaves the changes staged but uncommitted, so removing the file and adding a .gitignore entry is all it takes. The hard part is not detection but silence. A scanner that cries wolf gets answered with a reflexive "y" and stops being a safety net, so values that are plainly environment references, dotted identifiers, constant names, template slots or masked stand-ins are filtered out. A test scans mgsh's own README and mgshrc.example -- both full of credential-shaped text -- and fails if either would trip the check. It caught the documentation for this very feature, which is why the README describes the sample output instead of reproducing it. For a line that legitimately looks like a credential there is `mgsh:allow`, which suppresses that one line; `secretscan = off` turns the check off entirely. Only an explicit "off" does that -- a typo in the setting leaves the safety net in place, which is what the new falsy() is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+206
@@ -0,0 +1,206 @@
|
||||
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, "<>${}()") { // <token>, ${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(cGray, h.kind), col(cRed, ellipsis(strings.TrimSpace(h.text), 100)))
|
||||
}
|
||||
fmt.Println(col(cGray, " (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] + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user