`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>
213 lines
6.5 KiB
Go
213 lines
6.5 KiB
Go
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 = <personal-access-token>", // our own README
|
|
"# remotekey = <personal-access-token>", // 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 <base64(owner:token)>", // documentation
|
|
"remote.hub.key = <personal-access-token>",
|
|
}
|
|
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)
|
|
}
|
|
}
|