Phase B-1: ownership fixup via debugfs pass
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>
This commit is contained in:
parent
2e4d4b14da
commit
43982a4ae3
7 changed files with 334 additions and 32 deletions
|
|
@ -26,6 +26,9 @@ import (
|
|||
"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
|
||||
|
|
@ -188,7 +191,7 @@ func TestFlattenAppliesLayersAndWhiteouts(t *testing.T) {
|
|||
t.Fatalf("Pull: %v", err)
|
||||
}
|
||||
dest := t.TempDir()
|
||||
if err := Flatten(context.Background(), pulled, dest); err != nil {
|
||||
if _, err := Flatten(context.Background(), pulled, dest); err != nil {
|
||||
t.Fatalf("Flatten: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -227,7 +230,7 @@ func TestFlattenRejectsPathTraversal(t *testing.T) {
|
|||
t.Fatalf("Pull: %v", err)
|
||||
}
|
||||
dest := t.TempDir()
|
||||
err = Flatten(context.Background(), pulled, dest)
|
||||
_, 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)
|
||||
}
|
||||
|
|
@ -253,7 +256,7 @@ func TestFlattenAcceptsAbsoluteSymlink(t *testing.T) {
|
|||
t.Fatalf("Pull: %v", err)
|
||||
}
|
||||
dest := t.TempDir()
|
||||
if err := Flatten(context.Background(), pulled, dest); err != nil {
|
||||
if _, err := Flatten(context.Background(), pulled, dest); err != nil {
|
||||
t.Fatalf("Flatten: %v", err)
|
||||
}
|
||||
link := filepath.Join(dest, "etc/alternatives/awk")
|
||||
|
|
@ -280,7 +283,7 @@ func TestFlattenRejectsRelativeSymlinkEscape(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("Pull: %v", err)
|
||||
}
|
||||
err = Flatten(context.Background(), pulled, t.TempDir())
|
||||
_, err = Flatten(context.Background(), pulled, t.TempDir())
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe symlink") {
|
||||
t.Fatalf("Flatten relative escape: err=%v", err)
|
||||
}
|
||||
|
|
@ -317,6 +320,100 @@ func TestBuildExt4ProducesValidImage(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue