Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions AUDIT_OPEN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2578,3 +2578,17 @@ Gates: `go test ./... -count=1` 26/26 packages ok (macOS);
internal/deploy full suite PASS on Linux (colima, bash 5.2);
`GOOS=linux go vet ./... && GOOS=linux go build ./...` clean; `make
quickstart` green on colima.

## L14 — remote build context credential exposure (2026-09-24)

Resolved in the source-sync path: Git-ignored files no longer ride along with
remote builds, protected configuration/secret paths cannot be allowlisted,
ordinary generated artifacts have an explicit `.teployignore` include path,
and the resolved transfer list also drives provenance. Attempt directories
are mode 0700. Static releases exclude protected files too. `teploy doctor`
reports legacy exposure without mutating the server. See `docs/build-context.md`.

Validation: full Go tests and vet; source-selection/rsync argument tests;
private-directory permission tests; real repository context checks for Ship,
Dash and Observe. This change does not remove historical uploaded copies or
rotate potentially exposed credentials; the doctor reports those separately.
33 changes: 33 additions & 0 deletions docs/build-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Files sent to a remote build

Container builds upload tracked files and untracked files that Git does not
ignore. This follows `.gitignore`, `.git/info/exclude`, and the user's Git
excludes. Outside a Git worktree, all files are considered before exclusions.

Use `.teployignore` to exclude additional files. An explicit `!` pattern can
include a Git-ignored build artifact that the Dockerfile needs:

```
!/dist/
!/web/dist/
/web/dist/**/*.map
```

Exclusions take precedence over includes. Environment files, Git metadata,
node_modules, root Teploy configuration, destination overlays, and conventional
secret stores are protected and cannot be included by `!`. Keep credentials in
the deployment secret store instead of copying them into an image. Dockerfile
COPY/ADD checks report missing excluded inputs before upload and explain how to
include ordinary build artifacts.

The upload's selected paths also determine its recorded context fingerprint.
Each build attempt lives under an owner-only directory on the server; files
inside retain their source modes so unprivileged application processes can read
the files Docker copies into the image.

Static deployments also exclude protected files, but do not apply Git ignores:
static sources normally point at generated output directories.

`teploy doctor` reports older build directories that are traversable by other
users and potentially secret-bearing files under build/static release paths.
The check lists paths only and does not delete files or change permissions.
62 changes: 28 additions & 34 deletions internal/build/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,73 +224,67 @@ func TestPruneImages(t *testing.T) {
}
}

func TestLoadIgnore_Default(t *testing.T) {
func TestLoadRules_Default(t *testing.T) {
dir := t.TempDir()
patterns, err := LoadIgnore(dir)
rules, err := LoadRules(dir)
if err != nil {
t.Fatalf("LoadIgnore: %v", err)
t.Fatalf("LoadRules: %v", err)
}

if len(patterns) != len(DefaultIgnore) {
t.Fatalf("expected %d default patterns, got %d", len(DefaultIgnore), len(patterns))
if strings.Join(rules.Protected, "\n") != strings.Join(DefaultIgnore, "\n") {
t.Fatalf("protected = %v, want the defaults %v", rules.Protected, DefaultIgnore)
}
for i, p := range patterns {
if p != DefaultIgnore[i] {
t.Errorf("pattern %d: expected %s, got %s", i, DefaultIgnore[i], p)
}
if len(rules.Excludes) != 0 || len(rules.Includes) != 0 {
t.Fatalf("no .teployignore must add nothing: %+v", rules)
}
}

// TestLoadIgnore_CustomFileExtendsDefaults is the T51 regression: a custom
// TestLoadRules_CustomFileExtendsDefaults is the T51 regression: a custom
// .teployignore must EXTEND the protected defaults (.env/.git), never
// replace them — one custom pattern used to ship the .env file to the build
// host.
func TestLoadIgnore_CustomFileExtendsDefaults(t *testing.T) {
// host. `!` lines are the allowlist, not excludes.
func TestLoadRules_CustomFileExtendsDefaults(t *testing.T) {
dir := t.TempDir()
content := "vendor\n# comment\n.cache\n\nbuild\n"
content := "vendor\n# comment\n.cache\n\nbuild\n!/dist/\n"
os.WriteFile(filepath.Join(dir, ".teployignore"), []byte(content), 0644)

patterns, err := LoadIgnore(dir)
rules, err := LoadRules(dir)
if err != nil {
t.Fatalf("LoadIgnore: %v", err)
t.Fatalf("LoadRules: %v", err)
}

joined := "\n" + strings.Join(patterns, "\n") + "\n"
for _, protected := range DefaultIgnore {
if !strings.Contains(joined, "\n"+protected+"\n") {
t.Errorf("custom ignore file dropped the protected default %q: %v", protected, patterns)
}
if strings.Join(rules.Protected, "\n") != strings.Join(DefaultIgnore, "\n") {
t.Errorf("custom ignore file changed the protected defaults: %v", rules.Protected)
}
for _, custom := range []string{"vendor", ".cache", "build"} {
if !strings.Contains(joined, "\n"+custom+"\n") {
t.Errorf("custom pattern %q missing: %v", custom, patterns)
}
if got := strings.Join(rules.Excludes, ","); got != "vendor,.cache,build" {
t.Errorf("excludes = %q", got)
}
if got := strings.Join(rules.Includes, ","); got != "/dist/" {
t.Errorf("includes = %q", got)
}
}

func TestLoadIgnore_EmptyFile(t *testing.T) {
func TestLoadRules_EmptyFile(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, ".teployignore"), []byte("\n\n# only comments\n"), 0644)

patterns, err := LoadIgnore(dir)
rules, err := LoadRules(dir)
if err != nil {
t.Fatalf("LoadIgnore: %v", err)
t.Fatalf("LoadRules: %v", err)
}
if len(patterns) != len(DefaultIgnore) {
t.Fatalf("expected defaults for empty file, got %d patterns", len(patterns))
if len(rules.Excludes) != 0 || len(rules.Includes) != 0 {
t.Fatalf("expected defaults only for an empty file, got %+v", rules)
}
}

// TestLoadIgnore_UnreadableFileIsAnError is the T51 regression: read
// TestLoadRules_UnreadableFileIsAnError is the T51 regression: read
// failures used to fold into "no custom rules" and transfer silently.
func TestLoadIgnore_UnreadableFileIsAnError(t *testing.T) {
func TestLoadRules_UnreadableFileIsAnError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".teployignore")
os.WriteFile(path, []byte("vendor\n"), 0644)
if err := os.Chmod(path, 0000); err != nil {
t.Skip("cannot make the ignore file unreadable")
}
if _, err := LoadIgnore(dir); err == nil {
if _, err := LoadRules(dir); err == nil {
t.Error("an unreadable .teployignore must be an error, never a silent defaults-only transfer")
}
}
Expand Down
128 changes: 0 additions & 128 deletions internal/build/fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,139 +2,11 @@ package build

import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"runtime"
"sort"
)

// ContextFingerprint returns the sha256 fingerprint of the build-context
// tree dir would transfer: every file's path and content, every directory's
// path, and every symlink's target, with the given exclude patterns
// (rsync-style: matched against each entry's base name and its
// slash-separated relative path) left out — the fingerprint describes the
// SOURCE the builder consumes, not the operator's local clutter.
//
// Encoding discipline mirrors the static deployer's v3 tree hash (audit
// F51/TCL-38): typed, length-prefixed records for EVERY entry —
// directories included — emitted in sorted path order, so no two distinct
// trees can collide through framing ambiguity. Permission bits are ignored
// (umask stability across machines). Unlike the static hash, symlinks are
// INCLUDED, hashed by target: rsync -a preserves links into the build
// context, so a link is build input whose identity is what it points at.
// This is a provenance identity, not a security boundary.
func ContextFingerprint(dir string, excludes []string) (string, error) {
if dir == "" {
dir = "."
}
type entry struct {
rel string
kind byte // 'f' file, 'd' directory, 'l' symlink
size int64
digest [32]byte
target string
}
var entries []entry
pruned := func(rel string) bool {
if len(excludes) == 0 {
return false
}
base := filepath.Base(rel)
for _, pat := range excludes {
if pat == "" {
continue
}
if ok, _ := filepath.Match(pat, base); ok {
return true
}
if ok, _ := filepath.Match(pat, rel); ok {
return true
}
}
return false
}

root := filepath.Clean(dir)
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if p == root {
return nil
}
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if pruned(rel) {
if d.IsDir() {
return fs.SkipDir
}
return nil
}
info, err := d.Info()
if err != nil {
return err
}
switch {
case info.Mode()&fs.ModeSymlink != 0:
target, err := os.Readlink(p)
if err != nil {
return err
}
entries = append(entries, entry{rel: rel, kind: 'l', target: target})
case info.IsDir():
entries = append(entries, entry{rel: rel, kind: 'd'})
case info.Mode().IsRegular():
digest, size, err := hashFile(p)
if err != nil {
return err
}
entries = append(entries, entry{rel: rel, kind: 'f', size: size, digest: digest})
default:
// Sockets, devices and FIFOs cannot be synced as build input;
// record their presence so the fingerprint still moves.
entries = append(entries, entry{rel: rel, kind: 's'})
}
return nil
})
if err != nil {
return "", fmt.Errorf("fingerprinting build context %s: %w", dir, err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].rel < entries[j].rel })

h := sha256.New()
h.Write([]byte("teploy-context-v1\x00"))
var num [8]byte
binary.BigEndian.PutUint64(num[:], uint64(len(entries)))
h.Write(num[:])
var len8 [8]byte
writeStr := func(s string) {
binary.BigEndian.PutUint64(len8[:], uint64(len(s)))
h.Write(len8[:])
h.Write([]byte(s))
}
for _, e := range entries {
h.Write([]byte{e.kind})
writeStr(e.rel)
switch e.kind {
case 'f':
binary.BigEndian.PutUint64(len8[:], uint64(e.size))
h.Write(len8[:])
h.Write(e.digest[:])
case 'l':
writeStr(e.target)
}
}
return hex.EncodeToString(h.Sum(nil)), nil
}

func hashFile(path string) ([32]byte, int64, error) {
f, err := os.Open(path)
if err != nil {
Expand Down
29 changes: 19 additions & 10 deletions internal/build/fingerprint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ import (
"testing"
)

// contextFingerprint resolves dir's upload selection and fingerprints it.
func contextFingerprint(dir string) (string, error) {
src, err := ResolveSource(dir)
if err != nil {
return "", err
}
return src.Fingerprint("")
}

func writeTree(t *testing.T, dir string, files map[string]string) {
t.Helper()
for rel, content := range files {
Expand Down Expand Up @@ -37,11 +46,11 @@ func TestContextFingerprint_DeterministicAndSensitive(t *testing.T) {
writeTree(t, a, base)
writeTree(t, b, base)

fa, err := ContextFingerprint(a, DefaultIgnore)
fa, err := contextFingerprint(a)
if err != nil {
t.Fatalf("ContextFingerprint: %v", err)
}
fb, err := ContextFingerprint(b, DefaultIgnore)
fb, err := contextFingerprint(b)
if err != nil {
t.Fatalf("ContextFingerprint: %v", err)
}
Expand All @@ -50,7 +59,7 @@ func TestContextFingerprint_DeterministicAndSensitive(t *testing.T) {
}

// Re-resolving the SAME tree (a second attempt) is stable.
fa2, err := ContextFingerprint(a, DefaultIgnore)
fa2, err := contextFingerprint(a)
if err != nil || fa2 != fa {
t.Fatalf("same tree re-fingerprinted differently: %q vs %q (%v)", fa, fa2, err)
}
Expand All @@ -60,7 +69,7 @@ func TestContextFingerprint_DeterministicAndSensitive(t *testing.T) {
if err := os.WriteFile(filepath.Join(b, "main.go"), []byte("package mian"), 0o644); err != nil {
t.Fatal(err)
}
if fb, err = ContextFingerprint(b, DefaultIgnore); err != nil || fb == fa {
if fb, err = contextFingerprint(b); err != nil || fb == fa {
t.Fatalf("a content change must move the fingerprint: %q vs %q (%v)", fa, fb, err)
}

Expand All @@ -69,7 +78,7 @@ func TestContextFingerprint_DeterministicAndSensitive(t *testing.T) {
if err := os.Rename(filepath.Join(b, "docs"), filepath.Join(b, "docz")); err != nil {
t.Fatal(err)
}
if fb, err = ContextFingerprint(b, DefaultIgnore); err != nil || fb == fa {
if fb, err = contextFingerprint(b); err != nil || fb == fa {
t.Fatalf("a rename must move the fingerprint (path is part of identity): %q vs %q (%v)", fa, fb, err)
}

Expand All @@ -78,7 +87,7 @@ func TestContextFingerprint_DeterministicAndSensitive(t *testing.T) {
if err := os.MkdirAll(filepath.Join(b, "brand/new/dir"), 0o755); err != nil {
t.Fatal(err)
}
if fb, err = ContextFingerprint(b, DefaultIgnore); err != nil || fb == fa {
if fb, err = contextFingerprint(b); err != nil || fb == fa {
t.Fatalf("a new empty directory must move the fingerprint (TCL-38 parity): %q vs %q (%v)", fa, fb, err)
}
}
Expand All @@ -98,11 +107,11 @@ func TestContextFingerprint_HonorsExcludePatterns(t *testing.T) {
".env.local": "SECRET=2",
})

fa, err := ContextFingerprint(a, DefaultIgnore)
fa, err := contextFingerprint(a)
if err != nil {
t.Fatal(err)
}
fb, err := ContextFingerprint(b, DefaultIgnore)
fb, err := contextFingerprint(b)
if err != nil {
t.Fatal(err)
}
Expand All @@ -127,11 +136,11 @@ func TestContextFingerprint_SymlinkTargetMovesIdentity(t *testing.T) {
if err := os.Symlink("elsewhere", filepath.Join(b, "link")); err != nil {
t.Fatal(err)
}
fa, err := ContextFingerprint(a, DefaultIgnore)
fa, err := contextFingerprint(a)
if err != nil {
t.Fatal(err)
}
fb, err := ContextFingerprint(b, DefaultIgnore)
fb, err := contextFingerprint(b)
if err != nil {
t.Fatal(err)
}
Expand Down
Loading
Loading