diff --git a/go.mod b/go.mod index bc676bc5ca..d1a8c1f114 100644 --- a/go.mod +++ b/go.mod @@ -96,7 +96,6 @@ require ( k8s.io/apimachinery v0.36.1 k8s.io/client-go v0.36.1 k8s.io/kube-aggregator v0.36.1 - k8s.io/mount-utils v0.36.1 sigs.k8s.io/controller-runtime v0.24.1 ) @@ -248,7 +247,6 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect - github.com/moby/sys/mountinfo v0.7.2 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect diff --git a/go.sum b/go.sum index 6f3b5cded8..b94ed1ada4 100644 --- a/go.sum +++ b/go.sum @@ -646,8 +646,6 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= -github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= -github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -1154,8 +1152,6 @@ k8s.io/kube-aggregator v0.36.1 h1:IzNeRsJcTtgsiCyTgCR1pSwWCrXC1QZQWMTcBw18cFQ= k8s.io/kube-aggregator v0.36.1/go.mod h1:ROrIm5irUhVUJsKVCgBAAcXpK5IiqpdCn0Ka7LYMGs4= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= -k8s.io/mount-utils v0.36.1 h1:NWDFsdv+jfqPfa/LisnbEn1QyPNYjMNkmfEORXhyvZA= -k8s.io/mount-utils v0.36.1/go.mod h1:+I47UOG6FiUGVSy7VanjU/mQXLShMo3M7xBpGLzCub8= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= diff --git a/pkg/common/containerinfo/extract.go b/pkg/common/containerinfo/extract.go index ae4cb2d1d4..cae1e6a34b 100644 --- a/pkg/common/containerinfo/extract.go +++ b/pkg/common/containerinfo/extract.go @@ -18,7 +18,6 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "k8s.io/apimachinery/pkg/types" - "k8s.io/mount-utils" ) var ( @@ -77,7 +76,7 @@ func (e *Extractor) extractInfo(pid int32, log hclog.Logger, extractPodUID bool) func (e *Extractor) extractPodUIDAndContainerIDFromMountInfo(pid int32, log hclog.Logger, extractPodUID bool) (types.UID, string, error) { mountInfoPath := filepath.Join(e.RootDir, "/proc", fmt.Sprint(pid), "mountinfo") - mountInfos, err := mount.ParseMountInfo(mountInfoPath) + mountInfos, err := parseMountInfo(mountInfoPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { return "", "", nil diff --git a/pkg/common/containerinfo/extract_test.go b/pkg/common/containerinfo/extract_test.go index a3a6361d21..2e405e5b28 100644 --- a/pkg/common/containerinfo/extract_test.go +++ b/pkg/common/containerinfo/extract_test.go @@ -140,4 +140,12 @@ func TestExtractContainerID(t *testing.T) { t.Run("has multiple cgroup mounts on slash v2", func(t *testing.T) { assertFound(t, "testdata/docker/cgroup-mount-at-slash/v2", testContainerID) }) + + t.Run("tolerates tmpfs mount with empty source", func(t *testing.T) { + // Regression test for #7036: a tmpfs mount has no source, which renders + // as an empty field in /proc//mountinfo. The container ID must + // still be extracted from the cgroup mount on the same file rather than + // the whole file being rejected. + assertFound(t, "testdata/docker/tmpfs-empty-source", testContainerID) + }) } diff --git a/pkg/common/containerinfo/mountinfo.go b/pkg/common/containerinfo/mountinfo.go new file mode 100644 index 0000000000..2c36d8ea45 --- /dev/null +++ b/pkg/common/containerinfo/mountinfo.go @@ -0,0 +1,112 @@ +//go:build !windows + +package containerinfo + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// mountInfo holds the subset of /proc//mountinfo fields that the +// container info extractor consumes. The full mountinfo line format is +// documented in proc(5). +type mountInfo struct { + // Root is the pathname of the directory in the filesystem which forms the + // root of this mount (field 4). + Root string + // FsType is the filesystem type (the first field after the "-" separator). + FsType string +} + +// parseMountInfo parses /proc//mountinfo. +// +// It exists because k8s.io/mount-utils ParseMountInfo splits each line with +// strings.Fields, which collapses runs of whitespace and therefore drops an +// empty mount source field. Per proc(5) the mount source (the field after the +// filesystem type) may be empty (for example a tmpfs mount has no source), and +// the kernel escapes any real whitespace in a path as octal (\040). A line +// like: +// +// 119 206 0:68 / /local rw,relatime - tmpfs rw,size=8192k +// +// is therefore valid: the double space between "tmpfs" and "rw,size=8192k" +// unambiguously means an empty source. The upstream parser counts that as 9 +// fields (it expects at least 10) and rejects the entire file, which made the +// docker and k8s workload attestors fail attestation outright. This parser +// uses strings.Split (single-space delimiter) so the empty source field is +// preserved as an empty string rather than collapsed. +func parseMountInfo(filename string) ([]mountInfo, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + + var infos []mountInfo + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + info, err := parseMountInfoLine(line) + if err != nil { + return nil, err + } + infos = append(infos, info) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return infos, nil +} + +// parseMountInfoLine parses a single mountinfo line. The format (see proc(5)) +// is, in order: +// +// (1) mount ID (2) parent ID (3) major:minor (4) root (5) mount point +// (6) mount options (7..) zero or more optional fields (8) a "-" separator +// (9) filesystem type (10) mount source (11) super options +// +// Only the fields the extractor needs (root and filesystem type) are returned. +// The line is split on single spaces so that an empty mount source (field 10) +// is preserved as an empty string rather than collapsed by strings.Fields. +func parseMountInfoLine(line string) (mountInfo, error) { + // Split on single spaces so empty fields (e.g. an empty mount source + // between "tmpfs" and the super options) survive as empty strings. + fields := strings.Split(line, " ") + + // Locate the "-" separator. Everything before it is the fixed + + // optional-tag fields; everything after is fstype, source, super options. + sepIdx := -1 + for i, f := range fields { + if f == "-" { + sepIdx = i + break + } + } + if sepIdx < 0 { + return mountInfo{}, fmt.Errorf("missing separator in mountinfo line: %s", line) + } + + // Before the separator: mount ID (0), parent ID (1), major:minor (2), + // root (3), mount point (4), mount options (5), then zero or more + // optional fields. We need at least 6. + if sepIdx < 6 { + return mountInfo{}, fmt.Errorf("expected at least 6 fields before separator in mountinfo line: %s", line) + } + + // After the separator: filesystem type (sepIdx+1), mount source + // (sepIdx+2, may be empty), super options (sepIdx+3). We only need + // the filesystem type. + if sepIdx+1 >= len(fields) || fields[sepIdx+1] == "" { + return mountInfo{}, fmt.Errorf("missing filesystem type in mountinfo line: %s", line) + } + + return mountInfo{ + Root: fields[3], + FsType: fields[sepIdx+1], + }, nil +} diff --git a/pkg/common/containerinfo/mountinfo_test.go b/pkg/common/containerinfo/mountinfo_test.go new file mode 100644 index 0000000000..16b32f40cf --- /dev/null +++ b/pkg/common/containerinfo/mountinfo_test.go @@ -0,0 +1,90 @@ +//go:build !windows + +package containerinfo + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseMountInfoLine(t *testing.T) { + for _, tt := range []struct { + name string + line string + wantRoot string + wantType string + wantErr string + }{ + { + name: "normal cgroup2 line", + line: "1543 1542 0:32 /some/root /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw,nsdelegate", + wantRoot: "/some/root", + wantType: "cgroup2", + }, + { + name: "optional fields before separator", + line: "573 572 0:33 /docker/abc /sys/fs/cgroup/systemd ro,nosuid,nodev,noexec,relatime master:11 - cgroup cgroup rw,name=systemd", + wantRoot: "/docker/abc", + wantType: "cgroup", + }, + { + // Regression for #7036: a tmpfs mount has no source, so the field + // after the filesystem type is empty. strings.Fields would collapse + // it and the upstream parser rejected the whole file. + name: "tmpfs with empty source", + line: "119 206 0:68 / /local rw,relatime - tmpfs rw,size=8192k", + wantRoot: "/", + wantType: "tmpfs", + }, + { + name: "missing separator", + line: "1543 1542 0:32 /some/root /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime cgroup2 cgroup rw", + wantErr: "missing separator", + }, + { + name: "too few fields before separator", + line: "1543 1542 0:32 - cgroup2 cgroup rw", + wantErr: "expected at least 6 fields before separator", + }, + { + name: "missing filesystem type after separator", + line: "1543 1542 0:32 /some/root /sys/fs/cgroup rw - ", + wantErr: "missing filesystem type", + }, + } { + t.Run(tt.name, func(t *testing.T) { + info, err := parseMountInfoLine(tt.line) + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantRoot, info.Root) + assert.Equal(t, tt.wantType, info.FsType) + }) + } +} + +func TestParseMountInfo(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mountinfo") + content := "" + + "2356 2355 0:30 /../containerid /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw\n" + + "119 206 0:68 / /local rw,relatime - tmpfs rw,size=8192k\n" + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + infos, err := parseMountInfo(path) + require.NoError(t, err) + require.Len(t, infos, 2) + + assert.Equal(t, "/../containerid", infos[0].Root) + assert.Equal(t, "cgroup2", infos[0].FsType) + + // The tmpfs line with an empty source must still parse. + assert.Equal(t, "/", infos[1].Root) + assert.Equal(t, "tmpfs", infos[1].FsType) +} diff --git a/pkg/common/containerinfo/testdata/docker/tmpfs-empty-source/proc/123/mountinfo b/pkg/common/containerinfo/testdata/docker/tmpfs-empty-source/proc/123/mountinfo new file mode 100644 index 0000000000..dd80daaf15 --- /dev/null +++ b/pkg/common/containerinfo/testdata/docker/tmpfs-empty-source/proc/123/mountinfo @@ -0,0 +1,2 @@ +2356 2355 0:30 /../0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw +119 206 0:68 / /local rw,relatime - tmpfs rw,size=8192k