imagepull.Flatten now captures per-file uid/gid/mode/type from the tar headers as it walks layers, returning a Metadata map alongside the extracted tree. Whiteouts correctly drop the victim's metadata. The returned Metadata feeds the new imagepull.ApplyOwnership, which pipes a batched `set_inode_field` script to `debugfs -w -f -`. Why: mkfs.ext4 -d copies the runner's on-disk uids verbatim, so without this pass setuid binaries become setuid-nonroot and sshd refuses to start on the resulting image. With the pass, a pulled debian:bookworm has /usr/bin/sudo with uid=0 + setuid bit surviving intact. imagepull.BuildExt4 signature unchanged; ownership is applied as a separate step by the daemon orchestrator between BuildExt4 and StageBootArtifacts, keeping each helper focused. The seam (d.pullAndFlatten) now returns (Metadata, error) for test stubs to feed synthetic metadata. StdinRunner is a new duck-typed extension next to CommandRunner; the real system.Runner implements RunStdin, test mocks don't need to unless they exercise stdin. Prevents every existing mock from growing a new method. Tests: - TestFlattenCapturesHeaderMetadata: setuid bit + mode survive the tar-header walk - TestApplyOwnershipRewritesUidGidMode: real debugfs round-trip — create ext4 with runner's uid, apply synthetic metadata setting uid=0 + setuid mode, verify via `debugfs -R stat` that the inode now has uid=0 and mode 04755 - TestBuildOwnershipScriptDeterministic: sorted, well-formed sif script output Debugfs and mkfs.ext4 tests skip if the binaries aren't on PATH. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
427 lines
12 KiB
Go
427 lines
12 KiB
Go
package imagepull
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"banger/internal/system"
|
|
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
"github.com/google/go-containerregistry/pkg/registry"
|
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
|
"github.com/google/go-containerregistry/pkg/v1/empty"
|
|
"github.com/google/go-containerregistry/pkg/v1/mutate"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
"github.com/google/go-containerregistry/pkg/v1/tarball"
|
|
)
|
|
|
|
// ensure log import stays used even when registry-logging is silenced.
|
|
var _ = log.New
|
|
|
|
// tarMember is a single entry to put into a fake layer tarball.
|
|
type tarMember struct {
|
|
name string
|
|
mode int64
|
|
body []byte
|
|
link string // for symlinks / hardlinks
|
|
dir bool
|
|
symlink bool
|
|
hardlink bool
|
|
}
|
|
|
|
func buildTar(t *testing.T, members []tarMember) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
for _, m := range members {
|
|
hdr := &tar.Header{Name: m.name, Mode: m.mode}
|
|
switch {
|
|
case m.dir:
|
|
hdr.Typeflag = tar.TypeDir
|
|
if hdr.Mode == 0 {
|
|
hdr.Mode = 0o755
|
|
}
|
|
case m.symlink:
|
|
hdr.Typeflag = tar.TypeSymlink
|
|
hdr.Linkname = m.link
|
|
case m.hardlink:
|
|
hdr.Typeflag = tar.TypeLink
|
|
hdr.Linkname = m.link
|
|
default:
|
|
hdr.Typeflag = tar.TypeReg
|
|
hdr.Size = int64(len(m.body))
|
|
if hdr.Mode == 0 {
|
|
hdr.Mode = 0o644
|
|
}
|
|
}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
t.Fatalf("tar header: %v", err)
|
|
}
|
|
if hdr.Typeflag == tar.TypeReg && len(m.body) > 0 {
|
|
if _, err := tw.Write(m.body); err != nil {
|
|
t.Fatalf("tar write: %v", err)
|
|
}
|
|
}
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatalf("tar close: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func startRegistry(t *testing.T) string {
|
|
t.Helper()
|
|
srv := httptest.NewServer(registry.New(registry.Logger(log.New(io.Discard, "", 0))))
|
|
t.Cleanup(srv.Close)
|
|
u, err := url.Parse(srv.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return u.Host
|
|
}
|
|
|
|
func makeLayer(t *testing.T, members []tarMember) v1.Layer {
|
|
t.Helper()
|
|
body := buildTar(t, members)
|
|
layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
|
|
return io.NopCloser(bytes.NewReader(body)), nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("LayerFromOpener: %v", err)
|
|
}
|
|
return layer
|
|
}
|
|
|
|
// pushImage assembles a multi-layer image with linux/amd64 platform and
|
|
// pushes it under repo:tag. Returns the canonical reference.
|
|
func pushImage(t *testing.T, host, repo, tag string, layers ...v1.Layer) string {
|
|
t.Helper()
|
|
img, err := mutate.AppendLayers(empty.Image, layers...)
|
|
if err != nil {
|
|
t.Fatalf("AppendLayers: %v", err)
|
|
}
|
|
cfg, err := img.ConfigFile()
|
|
if err != nil {
|
|
t.Fatalf("ConfigFile: %v", err)
|
|
}
|
|
cfg.Architecture = "amd64"
|
|
cfg.OS = "linux"
|
|
img, err = mutate.ConfigFile(img, cfg)
|
|
if err != nil {
|
|
t.Fatalf("ConfigFile mutate: %v", err)
|
|
}
|
|
ref, err := name.NewTag(host + "/" + repo + ":" + tag)
|
|
if err != nil {
|
|
t.Fatalf("NewTag: %v", err)
|
|
}
|
|
if err := remote.Write(ref, img); err != nil {
|
|
t.Fatalf("remote.Write: %v", err)
|
|
}
|
|
return ref.String()
|
|
}
|
|
|
|
func TestPullCachesLayersAndReturnsImage(t *testing.T) {
|
|
host := startRegistry(t)
|
|
ref := pushImage(t, host, "banger/test", "v1",
|
|
makeLayer(t, []tarMember{
|
|
{name: "etc/", dir: true},
|
|
{name: "etc/hello", body: []byte("world")},
|
|
}),
|
|
)
|
|
|
|
cacheDir := t.TempDir()
|
|
pulled, err := Pull(context.Background(), ref, cacheDir)
|
|
if err != nil {
|
|
t.Fatalf("Pull: %v", err)
|
|
}
|
|
if pulled.Digest == "" {
|
|
t.Fatalf("Digest empty")
|
|
}
|
|
if pulled.Platform != "linux/amd64" {
|
|
t.Fatalf("Platform = %q", pulled.Platform)
|
|
}
|
|
// Cache should now hold at least one blob.
|
|
blobsRoot := filepath.Join(cacheDir, "blobs")
|
|
count := 0
|
|
_ = filepath.WalkDir(blobsRoot, func(_ string, d os.DirEntry, _ error) error {
|
|
if d != nil && !d.IsDir() {
|
|
count++
|
|
}
|
|
return nil
|
|
})
|
|
if count == 0 {
|
|
t.Fatalf("no blobs cached under %s", blobsRoot)
|
|
}
|
|
}
|
|
|
|
func TestFlattenAppliesLayersAndWhiteouts(t *testing.T) {
|
|
host := startRegistry(t)
|
|
ref := pushImage(t, host, "banger/test", "wh",
|
|
makeLayer(t, []tarMember{
|
|
{name: "etc/", dir: true},
|
|
{name: "etc/keep", body: []byte("keep")},
|
|
{name: "etc/old", body: []byte("old")},
|
|
}),
|
|
makeLayer(t, []tarMember{
|
|
{name: "etc/.wh.old"}, // delete etc/old
|
|
{name: "etc/new", body: []byte("new")}, // add etc/new
|
|
{name: "var/", dir: true},
|
|
{name: "var/log/", dir: true},
|
|
{name: "var/log/file", body: []byte("log")},
|
|
}),
|
|
makeLayer(t, []tarMember{
|
|
{name: "var/log/.wh..wh..opq"}, // wipe var/log contents from prior layers
|
|
{name: "var/log/fresh", body: []byte("fresh")},
|
|
}),
|
|
)
|
|
|
|
pulled, err := Pull(context.Background(), ref, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Pull: %v", err)
|
|
}
|
|
dest := t.TempDir()
|
|
if _, err := Flatten(context.Background(), pulled, dest); err != nil {
|
|
t.Fatalf("Flatten: %v", err)
|
|
}
|
|
|
|
checkFile := func(rel, want string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(filepath.Join(dest, rel))
|
|
if err != nil {
|
|
t.Errorf("read %s: %v", rel, err)
|
|
return
|
|
}
|
|
if string(data) != want {
|
|
t.Errorf("%s = %q, want %q", rel, string(data), want)
|
|
}
|
|
}
|
|
checkFile("etc/keep", "keep")
|
|
checkFile("etc/new", "new")
|
|
checkFile("var/log/fresh", "fresh")
|
|
|
|
if _, err := os.Stat(filepath.Join(dest, "etc/old")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("etc/old should have been whited out: stat err=%v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "var/log/file")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("var/log/file should have been wiped by opaque marker: stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestFlattenRejectsPathTraversal(t *testing.T) {
|
|
host := startRegistry(t)
|
|
ref := pushImage(t, host, "banger/test", "evil",
|
|
makeLayer(t, []tarMember{
|
|
{name: "../escape", body: []byte("bad")},
|
|
}),
|
|
)
|
|
pulled, err := Pull(context.Background(), ref, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Pull: %v", err)
|
|
}
|
|
dest := t.TempDir()
|
|
_, err = Flatten(context.Background(), pulled, dest)
|
|
if err == nil || !strings.Contains(err.Error(), "unsafe path") {
|
|
t.Fatalf("Flatten escape: err=%v, want unsafe path", err)
|
|
}
|
|
escape := filepath.Join(filepath.Dir(dest), "escape")
|
|
if _, statErr := os.Stat(escape); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Errorf("escape file should not exist: %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestFlattenAcceptsAbsoluteSymlink(t *testing.T) {
|
|
// Container layers regularly contain absolute symlinks like
|
|
// /usr/bin/mawk — they're interpreted relative to the rootfs at
|
|
// boot time, not against the host filesystem. They must extract
|
|
// cleanly.
|
|
host := startRegistry(t)
|
|
ref := pushImage(t, host, "banger/test", "abs-sym",
|
|
makeLayer(t, []tarMember{
|
|
{name: "etc/alternatives/awk", symlink: true, link: "/usr/bin/mawk"},
|
|
}),
|
|
)
|
|
pulled, err := Pull(context.Background(), ref, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Pull: %v", err)
|
|
}
|
|
dest := t.TempDir()
|
|
if _, err := Flatten(context.Background(), pulled, dest); err != nil {
|
|
t.Fatalf("Flatten: %v", err)
|
|
}
|
|
link := filepath.Join(dest, "etc/alternatives/awk")
|
|
target, err := os.Readlink(link)
|
|
if err != nil {
|
|
t.Fatalf("readlink: %v", err)
|
|
}
|
|
if target != "/usr/bin/mawk" {
|
|
t.Errorf("link target = %q, want /usr/bin/mawk", target)
|
|
}
|
|
}
|
|
|
|
func TestFlattenRejectsRelativeSymlinkEscape(t *testing.T) {
|
|
// Relative symlinks with .. must still be rejected: the resolved
|
|
// path can escape dest at the host level even if the in-VM
|
|
// resolution would be safe.
|
|
host := startRegistry(t)
|
|
ref := pushImage(t, host, "banger/test", "rel-escape",
|
|
makeLayer(t, []tarMember{
|
|
{name: "etc/evil", symlink: true, link: "../../../../etc/passwd"},
|
|
}),
|
|
)
|
|
pulled, err := Pull(context.Background(), ref, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Pull: %v", err)
|
|
}
|
|
_, err = Flatten(context.Background(), pulled, t.TempDir())
|
|
if err == nil || !strings.Contains(err.Error(), "unsafe symlink") {
|
|
t.Fatalf("Flatten relative escape: err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildExt4ProducesValidImage(t *testing.T) {
|
|
if _, err := exec.LookPath("mkfs.ext4"); err != nil {
|
|
t.Skip("mkfs.ext4 not available; skipping")
|
|
}
|
|
src := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(src, "etc"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(src, "etc", "hello"), []byte("hi"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := filepath.Join(t.TempDir(), "rootfs.ext4")
|
|
if err := BuildExt4(context.Background(), system.NewRunner(), src, out, MinExt4Size); err != nil {
|
|
t.Fatalf("BuildExt4: %v", err)
|
|
}
|
|
info, err := os.Stat(out)
|
|
if err != nil {
|
|
t.Fatalf("stat output: %v", err)
|
|
}
|
|
if info.Size() != MinExt4Size {
|
|
t.Errorf("ext4 size = %d, want %d", info.Size(), MinExt4Size)
|
|
}
|
|
// Quick sanity via file(1) — the ext4 superblock should be detectable.
|
|
if _, err := exec.LookPath("file"); err == nil {
|
|
out, _ := exec.Command("file", "-b", out).Output()
|
|
if !bytes.Contains(out, []byte("ext")) {
|
|
t.Errorf("file(1) does not see an ext filesystem: %s", out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFlattenCapturesHeaderMetadata(t *testing.T) {
|
|
host := startRegistry(t)
|
|
ref := pushImage(t, host, "banger/test", "meta",
|
|
makeLayer(t, []tarMember{
|
|
{name: "usr/bin/sudo", mode: 0o4755, body: []byte("setuid-bin")},
|
|
{name: "etc/", dir: true, mode: 0o755},
|
|
{name: "etc/link", symlink: true, link: "/usr/bin/sudo"},
|
|
}),
|
|
)
|
|
|
|
pulled, err := Pull(context.Background(), ref, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Pull: %v", err)
|
|
}
|
|
meta, err := Flatten(context.Background(), pulled, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Flatten: %v", err)
|
|
}
|
|
|
|
sudo, ok := meta.Entries["usr/bin/sudo"]
|
|
if !ok {
|
|
t.Fatalf("missing usr/bin/sudo entry: %+v", meta.Entries)
|
|
}
|
|
if sudo.Mode&0o4000 == 0 {
|
|
t.Errorf("setuid bit lost: mode=0%o", sudo.Mode)
|
|
}
|
|
if sudo.Mode&0o777 != 0o755 {
|
|
t.Errorf("perm bits = 0%o, want 0o755", sudo.Mode&0o777)
|
|
}
|
|
|
|
if _, ok := meta.Entries["etc"]; !ok {
|
|
t.Errorf("missing etc dir entry")
|
|
}
|
|
if _, ok := meta.Entries["etc/link"]; !ok {
|
|
t.Errorf("missing symlink entry")
|
|
}
|
|
}
|
|
|
|
func TestApplyOwnershipRewritesUidGidMode(t *testing.T) {
|
|
if _, err := exec.LookPath("mkfs.ext4"); err != nil {
|
|
t.Skip("mkfs.ext4 not available; skipping")
|
|
}
|
|
if _, err := exec.LookPath("debugfs"); err != nil {
|
|
t.Skip("debugfs not available; skipping")
|
|
}
|
|
|
|
// Stage a tiny source tree and build an ext4 with mkfs.ext4 -d.
|
|
src := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(src, "setuid-bin"), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := filepath.Join(t.TempDir(), "rootfs.ext4")
|
|
if err := BuildExt4(context.Background(), system.NewRunner(), src, out, MinExt4Size); err != nil {
|
|
t.Fatalf("BuildExt4: %v", err)
|
|
}
|
|
|
|
// Apply synthetic metadata: set uid=0 gid=0 mode=0o4755 on setuid-bin.
|
|
meta := Metadata{Entries: map[string]FileMeta{
|
|
"setuid-bin": {Uid: 0, Gid: 0, Mode: 0o4755, Type: tar.TypeReg},
|
|
}}
|
|
if err := ApplyOwnership(context.Background(), system.NewRunner(), out, meta); err != nil {
|
|
t.Fatalf("ApplyOwnership: %v", err)
|
|
}
|
|
|
|
// Read back the inode via debugfs.
|
|
statOut, err := exec.Command("debugfs", "-R", "stat /setuid-bin", out).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("debugfs stat: %v: %s", err, statOut)
|
|
}
|
|
s := string(statOut)
|
|
if !bytes.Contains([]byte(s), []byte("User: 0")) && !bytes.Contains([]byte(s), []byte("User: 0")) {
|
|
t.Errorf("uid not 0 after fixup. output:\n%s", s)
|
|
}
|
|
if !bytes.Contains([]byte(s), []byte("Mode: 04755")) && !bytes.Contains([]byte(s), []byte("Mode: 4755")) {
|
|
t.Errorf("setuid mode not applied. output:\n%s", s)
|
|
}
|
|
}
|
|
|
|
func TestBuildOwnershipScriptDeterministic(t *testing.T) {
|
|
meta := Metadata{Entries: map[string]FileMeta{
|
|
"b": {Uid: 0, Gid: 0, Mode: 0o755, Type: tar.TypeReg},
|
|
"a": {Uid: 0, Gid: 0, Mode: 0o755, Type: tar.TypeReg},
|
|
"a/x": {Uid: 0, Gid: 0, Mode: 0o644, Type: tar.TypeReg},
|
|
}}
|
|
got := buildOwnershipScript(meta).String()
|
|
// sorted: a, a/x, b
|
|
want := "set_inode_field /a uid 0\nset_inode_field /a gid 0\nset_inode_field /a mode 0100755\n" +
|
|
"set_inode_field /a/x uid 0\nset_inode_field /a/x gid 0\nset_inode_field /a/x mode 0100644\n" +
|
|
"set_inode_field /b uid 0\nset_inode_field /b gid 0\nset_inode_field /b mode 0100755\n"
|
|
if got != want {
|
|
t.Errorf("script mismatch\ngot:\n%s\nwant:\n%s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildExt4RejectsTinySize(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "rootfs.ext4")
|
|
err := BuildExt4(context.Background(), system.NewRunner(), src, out, 1024)
|
|
if err == nil || !strings.Contains(err.Error(), "below minimum") {
|
|
t.Fatalf("BuildExt4 tiny: err=%v", err)
|
|
}
|
|
if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Errorf("output file should not exist on rejection: %v", statErr)
|
|
}
|
|
}
|