railiance-forge/tools/receive-secret-scan/main.go
tegwick ea65606c12
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Batch small receive inputs within the unchanged scan budget
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
2026-10-18 13:25:08 +02:00

481 lines
14 KiB
Go

// receive-secret-scan is a content-silent, Linux pre-receive control adapter.
// It never changes refs, runs repository programs, or retrieves credentials.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
const scannerHash = "88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509"
const scannerVersion = "8.30.1"
const maxInput = 512 * 1024
const maxObject = 8 * 1024 * 1024
const maxTotal = 64 * 1024 * 1024
const maxObjects = 2000
const maxRefs = 10000
var oidPattern = regexp.MustCompile(`^(?:[0-9a-f]{40}|[0-9a-f]{64})$`)
var errLimit = errors.New("output-limit")
type result struct {
Status string `json:"status"`
Scope string `json:"scope"`
Reason string `json:"reason,omitempty"`
Scanned int `json:"scanned_objects"`
ScannedRefs int `json:"scanned_refs"`
BlockedID string `json:"blocked_object_id,omitempty"`
Version string `json:"scanner_version"`
}
type update struct{ Old, New, Ref string }
type cappedBuffer struct {
bytes.Buffer
limit int
}
func (b *cappedBuffer) Write(p []byte) (int, error) {
if len(p) > b.limit-b.Len() {
return 0, errLimit
}
return b.Buffer.Write(p)
}
func zero(s string) bool { return strings.Trim(s, "0") == "" }
func parseUpdates(raw []byte) ([]update, error) {
if len(raw) == 0 || len(raw) > maxInput || raw[len(raw)-1] != '\n' {
return nil, errors.New("invalid-update-input")
}
lines := strings.Split(string(raw[:len(raw)-1]), "\n")
if len(lines) > 256 {
return nil, errors.New("update-limit")
}
seen := map[string]bool{}
var out []update
for _, line := range lines {
fields := strings.Split(line, " ")
if len(fields) != 3 || !oidPattern.MatchString(fields[0]) || !oidPattern.MatchString(fields[1]) || len(fields[0]) != len(fields[1]) || len(fields[2]) > 1024 || !strings.HasPrefix(fields[2], "refs/") || strings.ContainsAny(fields[2], "\x00\r\n\t") || seen[fields[2]] || zero(fields[0]) && zero(fields[1]) {
return nil, errors.New("invalid-update-input")
}
seen[fields[2]] = true
out = append(out, update{fields[0], fields[1], fields[2]})
}
return out, nil
}
// Only receive-pack object-location variables cross into Git. Scanner processes
// get none. Never inherit arbitrary GIT_CONFIG_* or credential/provider settings.
func gitEnvironment() []string {
env := []string{"PATH=/usr/local/bin:/usr/bin:/bin", "HOME=/nonexistent", "LANG=C", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_NO_REPLACE_OBJECTS=1", "GIT_GRAFT_FILE=/dev/null", "GIT_NO_LAZY_FETCH=1", "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/false", "GIT_SSH_COMMAND=/bin/false"}
for _, key := range []string{"GIT_DIR", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_QUARANTINE_PATH"} {
if val, ok := os.LookupEnv(key); ok {
env = append(env, key+"="+val)
}
}
return env
}
func git(ctx context.Context, input []byte, limit int, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "/usr/bin/git", args...)
cmd.Env = gitEnvironment()
cmd.Stdin = bytes.NewReader(input)
b := &cappedBuffer{limit: limit}
cmd.Stdout = b
cmd.Stderr = io.Discard
if runChild(cmd) != nil {
return nil, errors.New("git-" + args[0] + "-failed")
}
return b.Bytes(), nil
}
func refs(ctx context.Context) ([]byte, map[string]string, error) {
raw, err := git(ctx, nil, 2*1024*1024, "for-each-ref", "--format=%(objectname) %(refname)")
if err != nil {
return nil, nil, err
}
found := map[string]string{}
for _, line := range strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n") {
if line == "" {
continue
}
parts := strings.Split(line, " ")
if len(parts) != 2 || !oidPattern.MatchString(parts[0]) || !strings.HasPrefix(parts[1], "refs/") {
return nil, nil, errors.New("invalid-ref-snapshot")
}
found[parts[1]] = parts[0]
}
if len(found) > maxRefs {
return nil, nil, errors.New("ref-limit")
}
return raw, found, nil
}
func pinScanner(path string) (*os.File, error) {
fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, errors.New("scanner-file-refused")
}
f := os.NewFile(uintptr(fd), path)
ok := false
defer func() {
if !ok {
f.Close()
}
}()
if err = verifyScannerFD(f); err != nil {
return nil, err
}
ok = true
return f, nil
}
func scan(ctx context.Context, f *os.File, dir string, content []byte) (bool, error) {
cmd := exec.CommandContext(ctx, "/proc/self/fd/3", "stdin", "--config", filepath.Join(dir, "scanner.toml"), "--gitleaks-ignore-path", filepath.Join(dir, "ignore"), "--ignore-gitleaks-allow", "--redact=100", "--no-banner", "--no-color", "--log-level", "fatal", "--exit-code", "20", "--timeout", "15")
cmd.ExtraFiles = []*os.File{f}
cmd.Dir = dir
cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=" + dir, "TMPDIR=" + dir, "LANG=C"}
cmd.Stdin = bytes.NewReader(content)
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
err := runChild(cmd)
if ctx.Err() != nil {
return false, errors.New("scan-deadline-exceeded")
}
if err == nil {
return false, nil
}
var exit *exec.ExitError
if errors.As(err, &exit) && exit.ExitCode() == 20 {
return true, nil
}
return false, errors.New("scanner-failed")
}
// Tree object names are covered too. Gitlinks refer to an external repository,
// so this version refuses them rather than claiming transitive coverage.
func treeContent(raw []byte, oidBytes int) ([]byte, error) {
var names []byte
for len(raw) > 0 {
space := bytes.IndexByte(raw, ' ')
nul := bytes.IndexByte(raw, 0)
if space <= 0 || nul <= space || len(raw) < nul+1+oidBytes {
return nil, errors.New("invalid-tree")
}
mode := string(raw[:space])
if mode == "160000" {
return nil, errors.New("external-gitlink-unverified")
}
if mode != "100644" && mode != "100755" && mode != "120000" && mode != "40000" {
return nil, errors.New("unsupported-tree-entry")
}
names = append(names, raw[space+1:nul]...)
names = append(names, '\n')
raw = raw[nul+1+oidBytes:]
}
return names, nil
}
func check(ctx context.Context, updates []update, binary string) (out result) {
out = result{Status: "unverified", Scope: "introduced-reachable-git-objects-and-updated-ref-names", Version: scannerVersion}
fail := func(err error) result { out.Reason = err.Error(); return out }
scanner, err := pinScanner(binary)
if err != nil {
return fail(err)
}
defer scanner.Close()
format, err := git(ctx, nil, 128, "rev-parse", "--show-object-format")
if err != nil {
return fail(err)
}
width := 40
if string(format) == "sha256\n" {
width = 64
} else if string(format) != "sha1\n" {
return fail(errors.New("unsupported-object-format"))
}
shallow, err := git(ctx, nil, 128, "rev-parse", "--is-shallow-repository")
if err != nil {
return fail(err)
}
if string(shallow) != "false\n" {
return fail(errors.New("shallow-repository-unverified"))
}
// Partial repositories may perform network retrieval while reading objects.
config, err := git(ctx, nil, 1024*1024, "config", "--local", "--includes", "--name-only", "--list")
if err != nil {
return fail(err)
}
for _, key := range strings.Split(strings.ToLower(string(config)), "\n") {
if key == "extensions.partialclone" || strings.HasSuffix(key, ".promisor") {
return fail(errors.New("partial-repository-unverified"))
}
}
snapshot, existing, err := refs(ctx)
if err != nil {
return fail(err)
}
tips := map[string]bool{}
for _, u := range updates {
if len(u.Old) != width {
return fail(errors.New("object-format-mismatch"))
}
if _, err = git(ctx, nil, 128, "check-ref-format", u.Ref); err != nil {
return fail(errors.New("invalid-ref-name"))
}
current, exists := existing[u.Ref]
if zero(u.Old) && exists || !zero(u.Old) && current != u.Old {
return fail(errors.New("old-ref-mismatch"))
}
if !zero(u.New) {
tips[u.New] = true
}
}
// Freeze exclusions from the pre-push snapshot, never a mutable --all read.
// Scan all introduced reachable objects, not just a tip diff or first parent.
var revisions strings.Builder
for tip := range tips {
revisions.WriteString(tip + "\n")
}
objects := map[string]bool{}
if len(tips) > 0 {
excluded := map[string]bool{}
for _, oid := range existing {
excluded[oid] = true
}
for oid := range excluded {
revisions.WriteString("^" + oid + "\n")
}
raw, e := git(ctx, []byte(revisions.String()), (maxObjects+1)*(width+1), "rev-list", "--objects", "--no-object-names", "--stdin")
if e != nil {
return fail(e)
}
for _, oid := range strings.Fields(string(raw)) {
if !oidPattern.MatchString(oid) || len(oid) != width {
return fail(errors.New("invalid-object-list"))
}
objects[oid] = true
}
}
if len(objects) > maxObjects {
return fail(errors.New("object-limit"))
}
ids := make([]string, 0, len(objects))
for oid := range objects {
ids = append(ids, oid)
}
sort.Strings(ids)
var metadata []byte
if len(ids) > 0 {
metadata, err = git(ctx, []byte(strings.Join(ids, "\n")+"\n"), (maxObjects+1)*100, "cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)")
if err != nil {
return fail(err)
}
}
lines := strings.Split(strings.TrimSuffix(string(metadata), "\n"), "\n")
if len(ids) == 0 {
lines = nil
}
if len(lines) != len(ids) {
return fail(errors.New("incomplete-object-metadata"))
}
// Reject oversized batches before reading/scanning any object payload.
type objectInfo struct {
kind string
size int64
}
var objectMetadata []objectInfo
total := int64(0)
for i, line := range lines {
parts := strings.Split(line, " ")
if len(parts) != 3 || parts[0] != ids[i] {
return fail(errors.New("invalid-object-metadata"))
}
kind := parts[1]
if kind != "blob" && kind != "commit" && kind != "tag" && kind != "tree" {
return fail(errors.New("unsupported-object-type"))
}
size, e := strconv.ParseInt(parts[2], 10, 64)
if e != nil || size < 0 || size > maxObject || total+size > maxTotal {
return fail(errors.New("object-byte-limit"))
}
total += size
objectMetadata = append(objectMetadata, objectInfo{kind, size})
}
dir, err := os.MkdirTemp("", "forge-secret-scan-")
if err != nil {
return fail(errors.New("temporary-directory-unavailable"))
}
defer os.RemoveAll(dir)
var storage *volatileStorage
defer func() {
if storage != nil {
if e := storage.close(); e != nil {
out.Status = "unverified"
out.Reason = e.Error()
}
}
}()
getStorage := func() error {
if storage == nil {
var e error
storage, e = newVolatileStorage()
return e
}
if !storage.live() {
return errors.New("cleanup-watchdog-unavailable")
}
return nil
}
if os.WriteFile(filepath.Join(dir, "scanner.toml"), []byte("[extend]\nuseDefault = true\n"), 0600) != nil || os.WriteFile(filepath.Join(dir, "ignore"), nil, 0600) != nil {
return fail(errors.New("scanner-config-unavailable"))
}
// Ref names can themselves contain recognized tokens. Never echo them.
for start := 0; start < len(updates); start += directoryBatchInputs {
end := start + directoryBatchInputs
if end > len(updates) {
end = len(updates)
}
if end-start == 1 {
blocked, e := scan(ctx, scanner, dir, []byte(updates[start].Ref))
if e != nil {
return fail(e)
}
out.ScannedRefs++
if blocked {
out.Status = "blocked"
out.Reason = "ref-name-detection"
return out
}
continue
}
if e := getStorage(); e != nil {
return fail(e)
}
var batch []scanInput
for i := start; i < end; i++ {
batch = append(batch, scanInput{fmt.Sprintf("ref-%d", i), []byte(updates[i].Ref)})
}
blocked, e := scanSmall(ctx, scanner, dir, storage, batch)
if e != nil {
return fail(e)
}
out.ScannedRefs += len(batch)
if blocked != "" {
out.Status = "blocked"
out.Reason = "ref-name-detection"
return out
}
}
var pending []scanInput
flush := func() error {
if len(pending) == 0 {
return nil
}
if e := getStorage(); e != nil {
return e
}
blocked, e := scanSmall(ctx, scanner, dir, storage, pending)
if e != nil {
return e
}
out.Scanned += len(pending)
pending = nil
if blocked != "" {
out.Status = "blocked"
out.BlockedID = blocked
}
return nil
}
for i, info := range objectMetadata {
kind, size := info.kind, info.size
raw, e := git(ctx, nil, int(size)+1, "cat-file", kind, ids[i])
if e != nil {
return fail(e)
}
if int64(len(raw)) != size {
return fail(errors.New("object-size-mismatch"))
}
if kind == "tree" {
raw, e = treeContent(raw, width/2)
if e != nil {
return fail(e)
}
}
if len(raw) <= directoryInputBytes {
pending = append(pending, scanInput{ids[i], raw})
if len(pending) == directoryBatchInputs {
if e = flush(); e != nil {
return fail(e)
}
if out.Status == "blocked" {
return out
}
}
continue
}
blocked, e := scan(ctx, scanner, dir, raw)
if e != nil {
return fail(e)
}
out.Scanned++
if blocked {
out.Status = "blocked"
out.BlockedID = ids[i]
return out
}
}
if err = flush(); err != nil {
return fail(err)
}
if out.Status == "blocked" {
return out
}
after, _, err := refs(ctx)
if err != nil {
return fail(err)
}
if !bytes.Equal(snapshot, after) {
return fail(errors.New("refs-changed-during-scan"))
}
out.Status = "clean"
return out
}
func main() {
if code, handled := internalProcess(os.Args[1:]); handled {
os.Exit(code)
}
flags := flag.NewFlagSet("receive-secret-scan", flag.ContinueOnError)
flags.SetOutput(io.Discard)
binary := flags.String("scanner", "", "reviewed Gitleaks executable")
out := result{Status: "unverified", Scope: "introduced-reachable-git-objects-and-updated-ref-names", Version: scannerVersion, Reason: "invalid-invocation"}
if flags.Parse(os.Args[1:]) == nil && *binary != "" && flags.NArg() == 0 {
raw, err := io.ReadAll(io.LimitReader(os.Stdin, maxInput+1))
if err == nil {
updates, e := parseUpdates(raw)
if e != nil {
out.Reason = e.Error()
} else {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
out = check(ctx, updates, *binary)
cancel()
}
}
}
// Only fixed reason codes, counts and an object ID may reach the caller.
encoded, _ := json.Marshal(out)
fmt.Println(string(encoded))
if out.Status == "clean" {
return
}
if out.Status == "blocked" {
os.Exit(1)
}
os.Exit(2)
}