Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c
425 lines
16 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|