package main import ( "os" "path/filepath" "strings" "testing" ) // TestMatchSecretCatchesRealCredentials: the shapes that actually leak. func TestMatchSecretCatchesRealCredentials(t *testing.T) { lines := []string{ "-----BEGIN OPENSSH PRIVATE KEY-----", "-----BEGIN RSA PRIVATE KEY-----", " ghp_aB3dEfGh1jKlMn0pQrStUvWxYz012345678", "GITLAB=glpat-aB3dEfGh1jKlMn0pQrSt", `aws_access_key_id = AKIAIOSFODNN7EXAMPLE`, "slack: xoxb-1234567890-abcdefghij", `API_KEY="s3cr3tV4lu3W1thStuff"`, "password = hunter2hunter2hunter2", "token: 'aB3dEfGh1jKlMn0pQrSt'", "auth-token=9f8e7d6c5b4a39281706abcdef123456", } for _, ln := range lines { if matchSecret(ln) == "" { t.Errorf("missed a credential in %q", ln) } } } // TestMatchSecretIgnoresNoise: everyday code and documentation must not trip // the prompt, or people learn to answer "yes" without reading it. func TestMatchSecretIgnoresNoise(t *testing.T) { lines := []string{ "remotekey = ", // our own README "# remotekey = ", // and mgshrc.example "token = process.env.GITHUB_TOKEN", // reference, not a value "const token = getToken()", // call "password = ${DB_PASSWORD}", // template `api_key = "changeme"`, // placeholder "secret: TODO", // "key gh***************xk", // masked, from `config` "password = xxxxxxxxxxxxxxxxxxxxxxx", // masked "// the token is never persisted in the repo", // prose "apiKey := os.Getenv(\"MGSH_REMOTEKEY\")", // lookup "token = SOME_CONSTANT_NAME", // constant "secret = my.config.value", // dotted reference "+++ b/token.go", // diff furniture "password = short", // too short to be one "Authorization: Basic ", // documentation "remote.hub.key = ", } for _, ln := range lines { if kind := matchSecret(ln); kind != "" { t.Errorf("false positive (%s) on %q", kind, ln) } } } // TestScanDiffReportsFileAndLine: the report has to point at the right place, // and must ignore removed lines — deleting a secret is the desired action. func TestScanDiffReportsFileAndLine(t *testing.T) { diff := `diff --git a/.env b/.env new file mode 100644 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +HOME=/tmp +API_KEY="s3cr3tV4lu3W1thStuff" +DEBUG=1 diff --git a/old.txt b/old.txt --- a/old.txt +++ b/old.txt @@ -7,1 +7,0 @@ -password = hunter2hunter2hunter2 ` hits := scanDiff(diff) if len(hits) != 1 { t.Fatalf("expected exactly one hit, got %d: %+v", len(hits), hits) } h := hits[0] if h.file != ".env" { t.Errorf("file = %q, want .env", h.file) } if h.lineNo != 2 { t.Errorf("lineNo = %d, want 2", h.lineNo) } if !strings.Contains(h.text, "API_KEY") { t.Errorf("text = %q", h.text) } } // TestScanDiffCountsLinesAcrossHunks keeps the line numbers honest when a file // is edited in several places. func TestScanDiffCountsLinesAcrossHunks(t *testing.T) { diff := `+++ b/config.yml @@ -1,0 +1,1 @@ +harmless: yes @@ -40,0 +41,2 @@ +also fine +aws_key = AKIAIOSFODNN7EXAMPLE ` hits := scanDiff(diff) if len(hits) != 1 || hits[0].lineNo != 42 { t.Fatalf("hits = %+v, want one at line 42", hits) } } // TestSecretsApprovedBlocksThePush drives the real thing: a staged .env, the // scan, and the answer deciding whether push continues. func TestSecretsApprovedBlocksThePush(t *testing.T) { dir := t.TempDir() mustGit(t, dir, "init", "-q") mustGit(t, dir, "config", "user.name", "t") mustGit(t, dir, "config", "user.email", "t@e") mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "base") old := cfg defer func() { cfg = old }() cfg = Config{} // clean tree: no prompt, no interference asked := fakeAnswers(t, false) if !secretsApproved(dir) { t.Fatal("a clean tree must not block the push") } if len(*asked) != 0 { t.Fatalf("asked about a clean tree: %v", *asked) } if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("API_KEY=\"s3cr3tV4lu3W1thStuff\"\n"), 0600); err != nil { t.Fatal(err) } mustGit(t, dir, "add", "--all", ".") declined := fakeAnswers(t, false) if secretsApproved(dir) { t.Error("a staged credential must stop the push when declined") } if len(*declined) == 0 { t.Error("the user was never asked") } accepted := fakeAnswers(t, true) if !secretsApproved(dir) { t.Error("an explicit yes must let the push through") } if len(*accepted) == 0 { t.Error("the user was never asked") } // and the escape hatch really switches it off cfg.SecretScan = "off" never := fakeAnswers(t, false) if !secretsApproved(dir) { t.Error("secretscan = off must not block") } if len(*never) != 0 { t.Errorf("secretscan = off still asked: %v", *never) } } // TestSecretScanDefaultsToOn: only a deliberate "off" disables it, so a typo // leaves the safety net in place. func TestSecretScanDefaultsToOn(t *testing.T) { old := cfg defer func() { cfg = old }() for _, v := range []string{"", "true", "on", "yes", "wharrgarbl", "1"} { cfg = Config{SecretScan: v} if !secretScanEnabled() { t.Errorf("secretscan = %q disabled the scan", v) } } for _, v := range []string{"off", "0", "false", "no", " OFF "} { cfg = Config{SecretScan: v} if secretScanEnabled() { t.Errorf("secretscan = %q did not disable the scan", v) } } } // TestOwnDocsDoNotTripTheScanner: mgsh's own README and example config are full // of credential-shaped text; committing mgsh itself must stay quiet. func TestOwnDocsDoNotTripTheScanner(t *testing.T) { for _, f := range []string{"README.md", "mgshrc.example"} { data, err := os.ReadFile(f) if err != nil { t.Fatal(err) } for i, ln := range strings.Split(string(data), "\n") { if kind := matchSecret(ln); kind != "" { t.Errorf("%s:%d would trip the scanner (%s): %q", f, i+1, kind, ln) } } } } // TestAllowMarkerSuppressesOneLine: the per-line escape for something that only // looks like a credential and is meant to stay. func TestAllowMarkerSuppressesOneLine(t *testing.T) { line := `API_KEY="s3cr3tV4lu3W1thStuff"` if matchSecret(line) == "" { t.Fatal("test line is not detected at all") } if kind := matchSecret(line + " # mgsh:allow — sample value"); kind != "" { t.Errorf("mgsh:allow did not suppress the hit (%s)", kind) } if kind := matchSecret("-----BEGIN OPENSSH PRIVATE KEY----- mgsh:allow"); kind != "" { t.Errorf("mgsh:allow did not suppress a certain pattern (%s)", kind) } }