Skip to content

Commit edad0b1

Browse files
committed
Replace Bash fix-permissions script with Go
* Easier to test * Can test more things * Prevents symlink shenanigans
1 parent a59a4b5 commit edad0b1

File tree

22 files changed

+480
-280
lines changed

22 files changed

+480
-280
lines changed

.buildkite/docker-compose.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
version: '3'
2+
3+
services:
4+
fixperms-tests:
5+
image: golang:latest
6+
working_dir: /code
7+
volumes:
8+
- ..:/code:ro
9+
command: go test -v ./...
10+
11+
fixperms-build:
12+
image: golang:latest
13+
working_dir: /code
14+
volumes:
15+
- ..:/code
16+
- /var/lib/buildkite-agent/git-mirrors:/var/lib/buildkite-agent/git-mirrors
17+
command: .buildkite/steps/build-fixperms.sh

.buildkite/pipeline.yaml

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,28 @@ steps:
77
agents:
88
queue: "${BUILDKITE_AGENT_META_DATA_QUEUE}"
99

10-
- id: "bats-tests"
11-
name: ":bash: Unit tests"
10+
- id: "fixperms-tests"
11+
name: ":go: fixperms tests"
1212
agents:
1313
queue: "${BUILDKITE_AGENT_META_DATA_QUEUE}"
1414
plugins:
15-
docker-compose#v2.1.0:
16-
run: unit-tests
17-
config: docker-compose.unit-tests.yml
15+
- docker-compose#v2.1.0:
16+
run: fixperms-tests
17+
config: .buildkite/docker-compose.yml
18+
19+
- id: "fixperms-build"
20+
name: ":go: fixperms build"
21+
agents:
22+
queue: "${BUILDKITE_AGENT_META_DATA_QUEUE}"
23+
depends_on:
24+
- "fixperms-tests"
25+
artifact_paths: "build/fix-perms-*"
26+
plugins:
27+
- docker-compose#v2.1.0:
28+
run: fixperms-build
29+
config: .buildkite/docker-compose.yml
30+
- artifacts#v1.9.0:
31+
upload: "builds/fix-perms-*"
1832

1933
- id: "deploy-service-role-stack"
2034
name: ":aws-iam: :cloudformation:"
@@ -23,7 +37,8 @@ steps:
2337
command: .buildkite/steps/deploy-service-role-stack.sh
2438
depends_on:
2539
- "lint"
26-
- "bats-tests"
40+
- "fixperms-tests"
41+
- "fixperms-build"
2742

2843
- id: "packer-windows-amd64"
2944
name: ":packer: :windows:"
@@ -34,7 +49,8 @@ steps:
3449
queue: "${BUILDKITE_AGENT_META_DATA_QUEUE}"
3550
depends_on:
3651
- "lint"
37-
- "bats-tests"
52+
- "fixperms-tests"
53+
- "fixperms-build"
3854

3955
- id: "launch-windows-amd64"
4056
name: ":cloudformation: :windows: AMD64 Launch"
@@ -77,7 +93,8 @@ steps:
7793
queue: "${BUILDKITE_AGENT_META_DATA_QUEUE}"
7894
depends_on:
7995
- "lint"
80-
- "bats-tests"
96+
- "fixperms-tests"
97+
- "fixperms-build"
8198

8299
- id: "launch-linux-amd64"
83100
name: ":cloudformation: :linux: AMD64 Launch"
@@ -119,7 +136,8 @@ steps:
119136
queue: "${BUILDKITE_AGENT_META_DATA_QUEUE}"
120137
depends_on:
121138
- "lint"
122-
- "bats-tests"
139+
- "fixperms-tests"
140+
- "fixperms-build"
123141

124142
- id: "launch-linux-arm64"
125143
name: ":cloudformation: :linux: ARM64 Launch"

.buildkite/steps/build-fixperms.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
for arch in amd64 arm64; do
4+
GOOS=linux GOARCH="${arch}" go build -v -o "build/fix-perms-linux-${arch}" ./internal/fixperms
5+
done

.buildkite/steps/packer.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ fi
1616

1717
mkdir -p "build/"
1818

19+
if [[ "$os" == "linux" ]] ; then
20+
buildkite-agent artifact download "build/fix-perms-linux-${arch}" ./build
21+
mv "build/fix-perms-linux-${arch}" packer/linux/conf/buildkite-agent/scripts/fix-buildkite-agent-builds-permissions
22+
chmod 755 packer/linux/conf/buildkite-agent/scripts/fix-buildkite-agent-builds-permissions
23+
fi
24+
1925
# Build a hash of packer files and the agent versions
2026
packer_files_sha=$(find Makefile "packer/${os}" plugins/ -type f -print0 | xargs -0 sha1sum | awk '{print $1}' | sort | sha1sum | awk '{print $1}')
2127
stable_agent_sha=$(curl -Lfs "https://download.buildkite.com/agent/stable/latest/${agent_binary}.sha256")

docker-compose.unit-tests.yml

Lines changed: 0 additions & 9 deletions
This file was deleted.

go.mod

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module github.com/buildkite/elastic-ci-stack-for-aws/v6
2+
3+
go 1.20
4+
5+
require (
6+
github.com/google/go-cmp v0.5.9
7+
golang.org/x/sys v0.12.0
8+
)

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
2+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
3+
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
4+
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

internal/fixperms/fdfs/fdfs.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//go:build linux
2+
3+
// Package fdfs is like os.DirFS, but with a file descriptor and openat(2),
4+
// fchownat(2), etc, to ensure symlinks do not escape.
5+
package fdfs
6+
7+
import (
8+
"io/fs"
9+
"os"
10+
11+
"golang.org/x/sys/unix"
12+
)
13+
14+
const resolveFlags = unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_MAGICLINKS | unix.RESOLVE_NO_XDEV
15+
16+
// FS uses a file descriptor for a directory as the base of a fs.FS.
17+
type FS uintptr
18+
19+
// DirFS opens the directory dir, and returns an FS rooted at that directory.
20+
// It uses open(2) with O_PATH+O_DIRECTORY+O_CLOEXEC.
21+
func DirFS(dir string) (FS, error) {
22+
bd, err := os.OpenFile(dir, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
23+
if err != nil {
24+
return 0, err
25+
}
26+
return FS(bd.Fd()), nil
27+
}
28+
29+
// Close closes the file descriptor.
30+
func (s FS) Close() error {
31+
return unix.Close(int(s))
32+
}
33+
34+
// Open wraps openat2(2) with O_RDONLY+O_NOFOLLOW+O_CLOEXEC.
35+
func (s FS) Open(path string) (fs.File, error) {
36+
fd, err := unix.Openat2(int(s), path, &unix.OpenHow{
37+
Flags: unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_CLOEXEC,
38+
Mode: 0,
39+
Resolve: resolveFlags,
40+
})
41+
if err != nil {
42+
return nil, err
43+
}
44+
f := os.NewFile(uintptr(fd), path)
45+
return f, nil
46+
}
47+
48+
// Lchown wraps fchownat(2) (with AT_SYMLINK_NOFOLLOW).
49+
func (s FS) Lchown(path string, uid, gid int) error {
50+
return unix.Fchownat(int(s), path, uid, gid, unix.AT_SYMLINK_NOFOLLOW)
51+
}
52+
53+
// Sub wraps openat2(2) (with O_PATH+O_DIRECTORY+O_NOFOLLOW+O_CLOEXEC), and returns an FS.
54+
func (s FS) Sub(dir string) (FS, error) {
55+
subFD, err := unix.Openat2(int(s), dir, &unix.OpenHow{
56+
Flags: unix.O_PATH | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC,
57+
Mode: 0,
58+
Resolve: resolveFlags,
59+
})
60+
if err != nil {
61+
return 0, err
62+
}
63+
return FS(subFD), nil
64+
}

internal/fixperms/fdfs/fdfs_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//go:build linux
2+
3+
package fdfs
4+
5+
import (
6+
"io/fs"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
)
11+
12+
func TestTOCTOUShenanigans(t *testing.T) {
13+
path := "/tmp/TestTOCTOUShenanigans/foo"
14+
if err := os.MkdirAll(path, 0o777); err != nil {
15+
t.Fatalf("os.MkdirAll(%s, %o) = %v", path, 0o777, err)
16+
}
17+
fp := filepath.Join(path, "data")
18+
if err := os.WriteFile(fp, []byte("innocent"), 0o666); err != nil {
19+
t.Fatalf("os.WriteFile(%s, nil, 0o666) = %v", fp, err)
20+
}
21+
22+
path2 := "/tmp/TestTOCTOUShenanigans/crimes"
23+
if err := os.MkdirAll(path2, 0o777); err != nil {
24+
t.Fatalf("os.MkdirAll(%s, %o) = %v", path2, 0o777, err)
25+
}
26+
fp2 := filepath.Join(path2, "data")
27+
if err := os.WriteFile(fp2, []byte("guilty"), 0o666); err != nil {
28+
t.Fatalf("os.WriteFile(%s, nil, 0o666) = %v", fp2, err)
29+
}
30+
31+
// Do it in two steps, to simulate a trusted directory and an untrusted
32+
// subpath.
33+
fsys, err := DirFS("/tmp/TestTOCTOUShenanigans")
34+
if err != nil {
35+
t.Fatalf("DirFS(/tmp/TestTOCTOUShenanigans) error = %v", err)
36+
}
37+
defer fsys.Close()
38+
fooFS, err := fsys.Sub("foo")
39+
if err != nil {
40+
t.Fatalf("DirFS(/tmp/TestTOCTOUShenanigans).Sub(foo) error = %v", err)
41+
}
42+
defer fooFS.Close()
43+
44+
// Replace foo with a symlink to crimes...
45+
path3 := "/tmp/TestTOCTOUShenanigans/foo.bak"
46+
if err := os.Rename(path, path3); err != nil {
47+
t.Fatalf("os.Rename(%s, %s) = %v", path, path3, err)
48+
}
49+
if err := os.Symlink(path2, path); err != nil {
50+
t.Fatalf("os.Symlink(%s, %s) = %v", path2, path, err)
51+
}
52+
53+
// What do we get?
54+
df, err := fs.ReadFile(fooFS, "data")
55+
if err != nil {
56+
t.Fatalf("fs.ReadFile(DirFS(%s), data) error = %v", path, err)
57+
}
58+
if got, want := string(df), "innocent"; got != want {
59+
t.Fatalf("fs.ReadFile(DirFS(%s), data) contents = %q, want %q", path, got, want)
60+
}
61+
}

internal/fixperms/fixer/fixer.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//go:buid linux
2+
3+
package fixer
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"io/fs"
9+
"os/user"
10+
"path/filepath"
11+
"strconv"
12+
"strings"
13+
14+
"github.com/buildkite/elastic-ci-stack-for-aws/v6/internal/fixperms/fdfs"
15+
)
16+
17+
// Main contains the higher-level operations of the permissions fixer.
18+
func Main(argv []string, baseDir, uname string) (string, int) {
19+
if len(argv) != 4 {
20+
return exitf(1, "Usage: %s AGENT_DIR ORG_DIR PIPELINE_DIR", argv[0])
21+
}
22+
for _, seg := range argv[1:] {
23+
if seg != filepath.Clean(seg) {
24+
return exitf(2, "Invalid argument %q", seg)
25+
}
26+
if seg == "." || seg == ".." || strings.ContainsRune(seg, '/') {
27+
return exitf(2, "Invalid argument %q", seg)
28+
}
29+
}
30+
subpath := filepath.Join(argv[1:]...)
31+
32+
// Get a file descriptor for the base builds directory.
33+
bd, err := fdfs.DirFS(baseDir)
34+
if err != nil {
35+
if errors.Is(err, fs.ErrNotExist) {
36+
return exit0()
37+
}
38+
return exitf(3, "Couldn't open %s: %v", baseDir, err)
39+
}
40+
defer bd.Close()
41+
42+
// Get a file descriptor for the agentdir/orgdir/pipelinedir within the
43+
// builds directory.
44+
// openat2(2) flags ensures this is within the builds directory, and does
45+
// not involve a symlink.
46+
pd, err := bd.Sub(subpath)
47+
if err != nil {
48+
if errors.Is(err, fs.ErrNotExist) {
49+
return exit0()
50+
}
51+
return exitf(3, "Couldn't open %s: %v", subpath, err)
52+
}
53+
defer pd.Close()
54+
55+
// Get the uid and gid of buildkite-agent
56+
agentUser, err := user.Lookup(uname)
57+
if err != nil {
58+
return exitf(4, "Couldn't look up buildkite-agent user: %v", err)
59+
}
60+
uid, err := strconv.Atoi(agentUser.Uid)
61+
if err != nil {
62+
return exitf(4, "buildkite-agent uid %q not an integer: %v", agentUser.Uid, err)
63+
}
64+
gid, err := strconv.Atoi(agentUser.Gid)
65+
if err != nil {
66+
return exitf(4, "buildkite-agent gid %q not an integer: %v", agentUser.Gid, err)
67+
}
68+
69+
// fs.WalkDir to find everything within the directory.
70+
// fchownat(2) to change the owner of the item.
71+
// We allow symlinks here, but operate on the symlinks themselves.
72+
if err := fs.WalkDir(pd, ".", func(path string, d fs.DirEntry, err error) error {
73+
return pd.Lchown(path, uid, gid)
74+
}); err != nil {
75+
return exitf(5, "Couldn't recursively chown %s: %v", subpath, err)
76+
}
77+
78+
return exit0()
79+
}
80+
81+
func exit0() (string, int) { return "", 0 }
82+
83+
func exitf(code int, f string, v ...any) (string, int) {
84+
return fmt.Sprintf(f, v...), code
85+
}

0 commit comments

Comments
 (0)