railiance-forge/tools/receive-secret-scan/main_test.go
tegwick 6d5c9e2abf
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
security: add bounded receive-path secret detection adapter
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c
2026-10-12 18:39:27 +02:00

425 lines
16 KiB
Go

package main
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
func TestUpdateInputRefusesAmbiguity(t *testing.T) {
z := strings.Repeat("0", 40)
a := strings.Repeat("a", 40)
valid := z + " " + a + " refs/heads/main\n"
for _, s := range []string{"", "\n", strings.TrimSuffix(valid, "\n"), z + " " + z + " refs/heads/x\n", valid + valid, z + " " + a + " --all\n", z + " " + a + " refs/heads/a b\n", strings.Repeat(valid, 257)} {
if _, err := parseUpdates([]byte(s)); err == nil {
t.Fatal("accepted invalid input")
}
}
if _, err := parseUpdates([]byte(valid)); err != nil {
t.Fatal(err)
}
}
func TestTreeRefusesMalformedAndExternalEntries(t *testing.T) {
for _, raw := range [][]byte{[]byte("bad"), []byte("100644 a\x00short"), append([]byte("160000 submodule\x00"), make([]byte, 20)...)} {
if _, err := treeContent(raw, 20); err == nil {
t.Fatal("accepted unsupported tree")
}
}
}
func TestGitEnvironmentDoesNotInheritConfigOrSecrets(t *testing.T) {
t.Setenv("GIT_CONFIG_COUNT", "1")
t.Setenv("GIT_CONFIG_KEY_0", "core.hooksPath")
t.Setenv("GIT_CONFIG_VALUE_0", "/untrusted")
t.Setenv("SCANNER_CREDENTIAL", "synthetic")
t.Setenv("GIT_OBJECT_DIRECTORY", "/reviewed/quarantine")
env := strings.Join(gitEnvironment(), "\n")
if strings.Contains(env, "untrusted") || strings.Contains(env, "CREDENTIAL") || strings.Contains(env, "GIT_CONFIG_COUNT") || !strings.Contains(env, "GIT_OBJECT_DIRECTORY=/reviewed/quarantine") {
t.Fatal("environment boundary failed")
}
}
func TestCappedOutput(t *testing.T) {
b := &cappedBuffer{limit: 3}
if _, err := b.Write([]byte("four")); err == nil || b.Len() != 0 {
t.Fatal("unbounded output")
}
}
func run(t *testing.T, dir string, input []byte, args ...string) []byte {
t.Helper()
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = dir
cmd.Stdin = bytes.NewReader(input)
cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("disposable command failed (output withheld), executable %s", filepath.Base(args[0]))
}
return out
}
func write(t *testing.T, path string, b []byte) {
t.Helper()
if os.WriteFile(path, b, 0600) != nil {
t.Fatal("fixture write failed")
}
}
func key(t *testing.T) []byte {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatal(err)
}
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
}
func binary(t *testing.T) (string, string) {
t.Helper()
scanner := os.Getenv("FORGE_TEST_GITLEAKS")
if scanner == "" {
t.Skip("requires explicitly admitted disposable Gitleaks binary")
}
if _, err := os.Stat(scanner); err != nil {
t.Fatal("configured test scanner missing")
}
path := filepath.Join(t.TempDir(), "adapter")
run(t, "", nil, "go", "build", "-buildvcs=false", "-o", path, ".")
return path, scanner
}
type fixture struct{ client, server, adapter, scanner string }
func setup(t *testing.T, adapter, scanner string) fixture {
t.Helper()
area := t.TempDir()
f := fixture{filepath.Join(area, "client"), filepath.Join(area, "remote.git"), adapter, scanner}
run(t, area, nil, "git", "init", "-q", "--bare", f.server)
run(t, area, nil, "git", "init", "-q", "-b", "main", f.client)
for _, dir := range []string{f.client, f.server} {
run(t, dir, nil, "git", "config", "core.hooksPath", filepath.Join(dir, "empty-hooks"))
}
run(t, f.client, nil, "git", "config", "user.name", "Disposable Test")
run(t, f.client, nil, "git", "config", "user.email", "probe@example.test")
f.commit(t, "source.txt", []byte("clean baseline\n"), "baseline")
run(t, f.client, nil, "git", "push", "-q", f.server, "HEAD:refs/heads/main")
run(t, f.server, nil, "git", "config", "core.hooksPath", filepath.Join(f.server, "hooks"))
f.install(t, scanner)
return f
}
func (f fixture) commit(t *testing.T, name string, content []byte, message string) string {
t.Helper()
write(t, filepath.Join(f.client, name), content)
run(t, f.client, nil, "git", "add", "--", name)
run(t, f.client, []byte(message), "git", "commit", "-q", "-F", "-")
return strings.TrimSpace(string(run(t, f.client, nil, "git", "rev-parse", "HEAD")))
}
func quote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" }
func (f fixture) install(t *testing.T, scanner string) {
t.Helper()
p := filepath.Join(f.server, "hooks", "pre-receive")
write(t, p, []byte("#!/bin/sh\nexec "+quote(f.adapter)+" --scanner "+quote(scanner)+"\n"))
if os.Chmod(p, 0755) != nil {
t.Fatal("chmod")
}
}
func (f fixture) push(t *testing.T, want int, args ...string) []byte {
t.Helper()
full := append([]string{"push", "--porcelain", f.server}, args...)
cmd := exec.Command("git", full...)
cmd.Dir = f.client
cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
out, err := cmd.CombinedOutput()
var observed result
for _, line := range bytes.Split(out, []byte("\n")) {
data := regexp.MustCompile(`\{"status":.*\}`).Find(line)
if len(data) > 0 {
_ = json.Unmarshal(data, &observed)
}
}
if want == 0 && err != nil || want != 0 && err == nil {
t.Fatalf("unexpected push outcome, want %d; status=%s reason=%s", want, observed.Status, observed.Reason)
}
expected := "clean"
if want == 1 {
expected = "blocked"
}
if want == 2 {
expected = "unverified"
}
if observed.Status != expected {
t.Fatalf("wrong control result, want %s; status=%s reason=%s", expected, observed.Status, observed.Reason)
}
if bytes.Contains(out, []byte("PRIVATE KEY")) {
t.Fatal("synthetic content leaked")
}
return out
}
func TestNativePushBoundaries(t *testing.T) {
adapter, scanner := binary(t)
t.Run("clean-existing-and-new-branch-and-deletion", func(t *testing.T) {
f := setup(t, adapter, scanner)
f.commit(t, "source.txt", []byte("changed clean\n"), "clean")
f.push(t, 0, "HEAD:refs/heads/main")
f.push(t, 0, "HEAD:refs/heads/feature")
f.push(t, 0, ":refs/heads/feature")
})
t.Run("initial-repository-clean-and-secret", func(t *testing.T) {
for _, secret := range []bool{false, true} {
t.Run(fmt.Sprint(secret), func(t *testing.T) {
f := setup(t, adapter, scanner)
fresh := filepath.Join(t.TempDir(), "fresh.git")
run(t, "", nil, "git", "init", "-q", "--bare", fresh)
run(t, fresh, nil, "git", "config", "core.hooksPath", filepath.Join(fresh, "hooks"))
f.server = fresh
f.install(t, scanner)
want := 0
if secret {
f.commit(t, "secret.txt", key(t), "fixture")
want = 1
}
f.push(t, want, "HEAD:refs/heads/main")
})
}
})
t.Run("sha256-object-format", func(t *testing.T) {
t.Setenv("GIT_DEFAULT_HASH", "sha256")
f := setup(t, adapter, scanner)
f.commit(t, "source.txt", []byte("clean change\n"), "clean")
f.push(t, 0, "HEAD:refs/heads/main")
f.commit(t, "secret.txt", key(t), "fixture")
f.push(t, 1, "HEAD:refs/heads/main")
})
t.Run("secret-in-tip", func(t *testing.T) {
f := setup(t, adapter, scanner)
oid := f.commit(t, "secret.txt", key(t), "synthetic fixture")
output := f.push(t, 1, "HEAD:refs/heads/main")
if !bytes.Contains(output, []byte(`"status":"blocked"`)) {
t.Fatal("not a detection")
}
c := exec.Command("git", "cat-file", "-e", oid)
c.Dir = f.server
if c.Run() == nil {
t.Fatal("rejected quarantined commit entered store")
}
})
t.Run("secret-added-then-deleted", func(t *testing.T) {
f := setup(t, adapter, scanner)
f.commit(t, "secret.txt", key(t), "temporary fixture")
run(t, f.client, nil, "git", "rm", "-q", "secret.txt")
run(t, f.client, nil, "git", "commit", "-qm", "removed fixture")
out := f.push(t, 1, "HEAD:refs/heads/main")
if !bytes.Contains(out, []byte(`"status":"blocked"`)) {
t.Fatal("history not scanned")
}
})
t.Run("new-branch-and-force-update", func(t *testing.T) {
f := setup(t, adapter, scanner)
f.commit(t, "secret.txt", key(t), "fixture")
f.push(t, 1, "HEAD:refs/heads/new")
f.push(t, 1, "+HEAD:refs/heads/main")
})
t.Run("merge-second-parent-history", func(t *testing.T) {
f := setup(t, adapter, scanner)
run(t, f.client, nil, "git", "checkout", "-qb", "side")
f.commit(t, "secret.txt", key(t), "fixture")
run(t, f.client, nil, "git", "rm", "-q", "secret.txt")
run(t, f.client, nil, "git", "commit", "-qm", "remove fixture")
run(t, f.client, nil, "git", "checkout", "-q", "main")
f.commit(t, "main.txt", []byte("clean\n"), "main")
run(t, f.client, nil, "git", "merge", "-q", "--no-ff", "-m", "merge", "side")
f.push(t, 1, "HEAD:refs/heads/main")
})
t.Run("commit-message", func(t *testing.T) {
f := setup(t, adapter, scanner)
f.commit(t, "source.txt", []byte("clean\n"), string(key(t)))
f.push(t, 1, "HEAD:refs/heads/main")
})
t.Run("annotated-tag-message", func(t *testing.T) {
f := setup(t, adapter, scanner)
run(t, f.client, key(t), "git", "tag", "-a", "synthetic", "-F", "-")
f.push(t, 1, "refs/tags/synthetic")
})
t.Run("tag-direct-to-blob", func(t *testing.T) {
f := setup(t, adapter, scanner)
oid := strings.TrimSpace(string(run(t, f.client, key(t), "git", "hash-object", "-w", "--stdin")))
run(t, f.client, nil, "git", "update-ref", "refs/tags/blob", oid)
f.push(t, 1, "refs/tags/blob")
})
t.Run("multi-ref-all-refused", func(t *testing.T) {
f := setup(t, adapter, scanner)
run(t, f.client, nil, "git", "branch", "clean")
f.commit(t, "secret.txt", key(t), "fixture")
f.push(t, 1, "clean:refs/heads/clean-new", "HEAD:refs/heads/main")
c := exec.Command("git", "show-ref", "--verify", "refs/heads/clean-new")
c.Dir = f.server
if c.Run() == nil {
t.Fatal("partial ref update")
}
})
t.Run("missing-scanner-and-deletion-refused", func(t *testing.T) {
f := setup(t, adapter, scanner)
f.install(t, filepath.Join(t.TempDir(), "missing"))
f.commit(t, "source.txt", []byte("clean\n"), "clean")
out := f.push(t, 2, "HEAD:refs/heads/main")
if !bytes.Contains(out, []byte(`"status":"unverified"`)) {
t.Fatal("missing scanner accepted")
}
f.push(t, 2, ":refs/heads/main")
})
t.Run("wrong-executable-refused", func(t *testing.T) {
f := setup(t, adapter, scanner)
fake := filepath.Join(t.TempDir(), "fake")
write(t, fake, []byte("#!/bin/sh\nexit 0\n"))
os.Chmod(fake, 0755)
f.install(t, fake)
f.commit(t, "source.txt", []byte("clean\n"), "clean")
f.push(t, 2, "HEAD:refs/heads/main")
})
t.Run("inline-and-config-suppression-refused", func(t *testing.T) {
f := setup(t, adapter, scanner)
f.commit(t, ".gitleaks.toml", []byte("[allowlist]\nregexes = ['.*']\n"), "config")
f.commit(t, "secret.txt", append([]byte("# gitleaks:allow\n"), key(t)...), "fixture")
f.push(t, 1, "HEAD:refs/heads/main")
})
t.Run("replacement-objects-cannot-hide-secret", func(t *testing.T) {
f := setup(t, adapter, scanner)
clean := strings.TrimSpace(string(run(t, f.client, nil, "git", "rev-parse", "HEAD")))
bad := f.commit(t, "secret.txt", key(t), "fixture")
run(t, f.server, nil, "git", "fetch", "-q", f.client, "HEAD")
run(t, f.server, nil, "git", "replace", bad, clean)
f.push(t, 1, "HEAD:refs/heads/main")
})
t.Run("grafts-cannot-hide-introduced-history", func(t *testing.T) {
f := setup(t, adapter, scanner)
f.commit(t, "secret.txt", key(t), "fixture")
run(t, f.client, nil, "git", "rm", "-q", "secret.txt")
run(t, f.client, nil, "git", "commit", "-qm", "remove")
tip := strings.TrimSpace(string(run(t, f.client, nil, "git", "rev-parse", "HEAD")))
write(t, filepath.Join(f.server, "info/grafts"), []byte(tip+"\n"))
f.push(t, 1, "HEAD:refs/heads/main")
})
t.Run("ref-name-token", func(t *testing.T) {
f := setup(t, adapter, scanner)
random := make([]byte, 10)
rand.Read(random)
f.push(t, 1, "HEAD:refs/heads/glpat-"+hex.EncodeToString(random))
})
t.Run("partial-config-include-refused", func(t *testing.T) {
f := setup(t, adapter, scanner)
config := filepath.Join(t.TempDir(), "partial-config")
write(t, config, []byte("[remote \"origin\"]\n promisor = true\n"))
run(t, f.server, nil, "git", "config", "include.path", config)
f.commit(t, "source.txt", []byte("clean\n"), "clean")
f.push(t, 2, "HEAD:refs/heads/main")
})
t.Run("object-count-limit-refused", func(t *testing.T) {
f := setup(t, adapter, scanner)
for i := 0; i < maxObjects; i++ {
write(t, filepath.Join(f.client, fmt.Sprintf("file-%d.txt", i)), []byte(fmt.Sprintf("unique clean %d\n", i)))
}
run(t, f.client, nil, "git", "add", ".")
run(t, f.client, nil, "git", "commit", "-qm", "large object set")
f.push(t, 2, "HEAD:refs/heads/main")
})
t.Run("external-gitlink-refused", func(t *testing.T) {
f := setup(t, adapter, scanner)
head := strings.TrimSpace(string(run(t, f.client, nil, "git", "rev-parse", "HEAD")))
run(t, f.client, nil, "git", "update-index", "--add", "--cacheinfo", "160000,"+head+",external")
run(t, f.client, nil, "git", "commit", "-qm", "external gitlink")
f.push(t, 2, "HEAD:refs/heads/main")
})
t.Run("partial-repository-refused", func(t *testing.T) {
f := setup(t, adapter, scanner)
run(t, f.server, nil, "git", "config", "remote.origin.promisor", "true")
f.commit(t, "source.txt", []byte("clean\n"), "clean")
f.push(t, 2, "HEAD:refs/heads/main")
})
t.Run("aggregate-byte-limit-refused", func(t *testing.T) {
f := setup(t, adapter, scanner)
for i := 0; i < 9; i++ {
line := []byte(fmt.Sprintf("clean input %d\n", i))
data := bytes.Repeat(line, (maxObject-1024)/len(line))
write(t, filepath.Join(f.client, fmt.Sprintf("large-%d.txt", i)), data)
}
run(t, f.client, nil, "git", "add", ".")
run(t, f.client, nil, "git", "commit", "-qm", "large aggregate")
out := f.push(t, 2, "HEAD:refs/heads/main")
if !bytes.Contains(out, []byte(`"reason":"object-byte-limit"`)) || !bytes.Contains(out, []byte(`"scanned_objects":0`)) {
t.Fatal("aggregate bytes not bounded")
}
})
t.Run("oversized-object-refused", func(t *testing.T) {
f := setup(t, adapter, scanner)
f.commit(t, "large.txt", bytes.Repeat([]byte("x"), maxObject+1), "large")
f.push(t, 2, "HEAD:refs/heads/main")
})
}
func TestScannerFileAndTimeoutRefusal(t *testing.T) {
adapter, scanner := binary(t)
f := setup(t, adapter, scanner)
t.Run("nonexecutable", func(t *testing.T) {
p := filepath.Join(t.TempDir(), "scanner")
b, _ := os.ReadFile(scanner)
write(t, p, b)
if file, err := pinScanner(p); err == nil {
file.Close()
t.Fatal("accepted")
}
})
t.Run("symlink", func(t *testing.T) {
p := filepath.Join(t.TempDir(), "scanner")
os.Symlink(scanner, p)
if file, err := pinScanner(p); err == nil {
file.Close()
t.Fatal("accepted")
}
})
t.Run("expired-context", func(t *testing.T) {
old, _ := os.Getwd()
os.Chdir(f.server)
defer os.Chdir(old)
before := strings.TrimSpace(string(run(t, f.server, nil, "git", "rev-parse", "refs/heads/main")))
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
got := check(ctx, []update{{before, before, "refs/heads/main"}}, scanner)
if got.Status != "unverified" {
t.Fatal("timeout accepted")
}
})
t.Run("scanner-process-failure", func(t *testing.T) {
pinned, err := pinScanner(scanner)
if err != nil {
t.Fatal(err)
}
defer pinned.Close()
_, err = scan(context.Background(), pinned, t.TempDir(), []byte("clean"))
if err == nil || err.Error() != "scanner-failed" {
t.Fatal("scanner failure accepted")
}
})
t.Run("unknown-old-ref", func(t *testing.T) {
cmd := exec.Command(adapter, "--scanner", scanner)
cmd.Dir = f.server
cmd.Stdin = strings.NewReader(strings.Repeat("b", 40) + " " + strings.Repeat("a", 40) + " refs/heads/main\n")
raw, err := cmd.Output()
if err == nil {
t.Fatal("accepted mismatch")
}
var got result
if json.Unmarshal(raw, &got) != nil || got.Reason != "old-ref-mismatch" {
t.Fatal("wrong refusal")
}
})
}