mirror of
https://github.com/hanzoai/visor.git
synced 2026-09-24 17:04:14 +00:00
1428 lines
39 KiB
Go
1428 lines
39 KiB
Go
// Copyright 2026 The gVisor Authors.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// Package sandboxposture collects the externally-observable Linux security
|
|
// posture of a running `runsc boot` (Sentry) process, and compares it against
|
|
// expectations depending on the runsc configuration.
|
|
package sandboxposture
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/moby/sys/capability"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
// Normalized namespace classifications, used by every field of `Namespaces`.
|
|
const (
|
|
// NSInherited means the namespace is the same as the reference process's.
|
|
NSInherited = "inherited"
|
|
|
|
// NSNew means the namespace is *not* the reference process's namespace.
|
|
NSNew = "new"
|
|
|
|
// NSJoinedPrefix prefixes a namespace that matches the specific namespace
|
|
// named after the prefix. See `NSJoined`.
|
|
NSJoinedPrefix = "joined:"
|
|
)
|
|
|
|
// NSJoined returns the namespace value meaning "this namespace is the one
|
|
// registered under `name` in `Opts.NamespaceTargets`".
|
|
func NSJoined(name string) string { return NSJoinedPrefix + name }
|
|
|
|
// Normalized values for `Credentials.Groups`.
|
|
const (
|
|
// GroupsNone means the process has an empty supplementary group list.
|
|
GroupsNone = "none"
|
|
|
|
// GroupsSameAsRef means the process's supplementary group list is the same
|
|
// as the reference process's.
|
|
GroupsSameAsRef = "same-as-ref"
|
|
)
|
|
|
|
// Normalized values for `Credentials.UID` and `Credentials.GID`.
|
|
const (
|
|
// IDSameAsRef means the ID is the reference process's ID.
|
|
IDSameAsRef = "same-as-ref"
|
|
|
|
// IDNobody is the conventional "nobody" unprivileged ID.
|
|
IDNobody = "nobody(65534)"
|
|
|
|
// IDRoot is uid/gid 0. Note that inside a user namespace, this means
|
|
// "root of that user namespace", not necessarily root-userns root.
|
|
IDRoot = "root(0)"
|
|
)
|
|
|
|
// Normalized values for `Filesystem.Root` (as in filesystem).
|
|
const (
|
|
// RootSameAsRef means the process shares the reference process's root
|
|
// directory.
|
|
RootSameAsRef = "same-as-ref"
|
|
|
|
// RootOwn means the process's root directory is *not* the reference
|
|
// process's root directory: it has been chrooted or pivot_root'ed away.
|
|
RootOwn = "own"
|
|
)
|
|
|
|
// Normalized values for `Attrs.UIDMap` and `Attrs.GIDMap`.
|
|
const (
|
|
// MapSameAsRef means the process is in the reference process's user
|
|
// namespace.
|
|
MapSameAsRef = "same-as-ref"
|
|
|
|
// MapIdentityOfRef means the process is in a new user namespace whose map
|
|
// covers exactly the same IDs as the reference process's map, mapped to
|
|
// themselves.
|
|
MapIdentityOfRef = "identity-of-ref"
|
|
|
|
// MapNobodyToNobody means the map is the single entry "65534 65534 1".
|
|
MapNobodyToNobody = "nobody->nobody"
|
|
|
|
// MapNobodyToRef means the map is the single entry "65534 <refuid> 1".
|
|
MapNobodyToRef = "nobody->ref"
|
|
|
|
// MapEmpty means the map file is empty.
|
|
// A process in this state has no valid IDs.
|
|
MapEmpty = "empty"
|
|
)
|
|
|
|
// Normalized values for `Attrs.ProcFileOwner`.
|
|
const (
|
|
// OwnerSameAsProcess means the files under `/proc/<pid>` are owned by the
|
|
// process's own effective IDs, which is what the kernel does for a
|
|
// "dumpable" process.
|
|
OwnerSameAsProcess = "same-as-process"
|
|
|
|
// OwnerRoot means the files under `/proc/<pid>` are owned by 0:0 while
|
|
// the process itself runs as something else. The kernel does this when a
|
|
// process is not "dumpable", so this doubles as the observable proxy for
|
|
// dumpability (see `Attrs.ProcFileOwner`).
|
|
OwnerRoot = "root"
|
|
)
|
|
|
|
// Normalized values for `Attrs.Limits`.
|
|
const (
|
|
// LimitSameAsRef means the limit is identical to the reference process's.
|
|
LimitSameAsRef = "same-as-ref"
|
|
|
|
// LimitUnlimited means both soft and hard limit are `RLIM_INFINITY`.
|
|
LimitUnlimited = "unlimited/unlimited"
|
|
)
|
|
|
|
// FD classes reported in `FDTable.Classes`.
|
|
const (
|
|
FDDirectory = "dir"
|
|
FDSocket = "socket"
|
|
FDPipe = "pipe"
|
|
FDNullDev = "null-dev"
|
|
FDRegular = "regular"
|
|
FDDeleted = "regular-deleted"
|
|
FDCharDev = "chardev"
|
|
FDBlockDev = "blockdev"
|
|
FDFifo = "fifo"
|
|
FDSymlink = "symlink"
|
|
FDUnknown = "unknown"
|
|
|
|
// FDAnonPrefix prefixes anonymous inode FDs, e.g. "anon:eventfd".
|
|
FDAnonPrefix = "anon:"
|
|
|
|
// FDMemfdPrefix prefixes memfd FDs, e.g. "memfd:systrap-memory".
|
|
FDMemfdPrefix = "memfd:"
|
|
)
|
|
|
|
// Posture is a normalized, comparable view of a process's
|
|
// externally-observable Linux security posture.
|
|
type Posture struct {
|
|
// Creds is the process's user and group identity.
|
|
Creds Credentials
|
|
|
|
// Caps is the process's capability sets.
|
|
Caps Capabilities
|
|
|
|
// Seccomp is the process's seccomp status.
|
|
Seccomp Seccomp
|
|
|
|
// Namespaces classifies each namespace the process is in.
|
|
Namespaces Namespaces
|
|
|
|
// FS is the process's view of the filesystem.
|
|
FS Filesystem
|
|
|
|
// FDs describes what the process holds open.
|
|
FDs FDTable
|
|
|
|
// Attrs holds the process attributes.
|
|
Attrs Attrs
|
|
|
|
// Threads reports whether every thread in the process all share the
|
|
// same process-level credentials, capabilities and seccomp state.
|
|
Threads Threads
|
|
|
|
// Children counts the process's direct children by command name.
|
|
// Useful for debugging, not used for comparison.
|
|
Children map[string]int `posture:"info"`
|
|
}
|
|
|
|
// Credentials is a process's user and group identity.
|
|
type Credentials struct {
|
|
// UID is the real/effective/saved/filesystem UID, normalized.
|
|
UID string
|
|
|
|
// GID is the group equivalent of `UID`.
|
|
GID string
|
|
|
|
// ExtraGroups is the sorted set of supplementary groups the process holds
|
|
// that the reference process did not (excluding the overflow GID).
|
|
ExtraGroups []string
|
|
|
|
// Groups is the raw supplementary group list. Informational only.
|
|
Groups string `posture:"info"`
|
|
}
|
|
|
|
// overflowID is the kernel's overflowuid/overflowgid.
|
|
// It is also the conventional "nobody" ID.
|
|
const overflowID = 65534
|
|
|
|
// Capabilities is a process's capability sets, each as a sorted list, with
|
|
// a "CAP_" prefix.
|
|
type Capabilities struct {
|
|
Inheritable []string
|
|
Permitted []string
|
|
Effective []string
|
|
Bounding []string
|
|
Ambient []string
|
|
}
|
|
|
|
// Seccomp is a process's seccomp state.
|
|
//
|
|
// Does not test for filter contents, as this requires custom kernel settings.
|
|
// Other gVisor tests cover seccomp filter contents.
|
|
type Seccomp struct {
|
|
// Mode is the value of the Seccomp field in procfs status:
|
|
// 0 = disabled, 1 = strict, 2 = filtered.
|
|
Mode int
|
|
|
|
// NumFilters is the value of the Seccomp_filters field in procfs status,
|
|
// i.e. how many filter programs are attached. Informational only.
|
|
NumFilters int `posture:"info"`
|
|
|
|
// NoNewPrivs is the `no_new_privs` bit, which must be set before a
|
|
// non-privileged process may install a filter, and which prevents
|
|
// subsequent execve's from regaining privileges.
|
|
NoNewPrivs int
|
|
}
|
|
|
|
// Namespaces classifies each of a process's namespaces relative to the
|
|
// reference process and to any named targets.
|
|
type Namespaces struct {
|
|
Mnt string
|
|
PID string
|
|
IPC string
|
|
UTS string
|
|
Net string
|
|
User string
|
|
Cgroup string
|
|
Time string
|
|
}
|
|
|
|
// Filesystem is a process's view of the filesystem.
|
|
type Filesystem struct {
|
|
// Root is `RootOwn` or `RootSameAsRef`.
|
|
Root string
|
|
|
|
// Entries is a recursive listing of the process's root directory, sorted,
|
|
// with paths relative to that root and directories suffixed with "/".
|
|
// Does not cross mountpoints.
|
|
//
|
|
// Compared as a subset: the sandbox may see fewer files than expected,
|
|
// but never more.
|
|
Entries []string `posture:"subset"`
|
|
|
|
// Cwd is the working directory as seen from inside the process's own root.
|
|
Cwd string
|
|
|
|
// ReadOnly reports whether the filesystem mounted at the process's root is
|
|
// read-only.
|
|
ReadOnly bool
|
|
|
|
// Mounts is the process's mount table, one entry per line, normalized to
|
|
// "<mountpoint> <fstype> <flags>" with flags restricted to the
|
|
// security-relevant ones + deduped + sorted.
|
|
Mounts []string
|
|
|
|
// RootLink is the raw target of `/proc/<pid>/root`, for debugging.
|
|
RootLink string `posture:"info"`
|
|
}
|
|
|
|
// FDTable describes a process's open file descriptors.
|
|
type FDTable struct {
|
|
// Classes is the sorted set of distinct FD classes the process holds.
|
|
// It is a set, not a count, because the number of memfds or eventfds a
|
|
// sandbox holds is an implementation detail while the *kind* of thing it
|
|
// holds is its security posture.
|
|
//
|
|
// Compared as a subset, as the the sandbox holding fewer kinds of FDs
|
|
// than expected is not a regression.
|
|
Classes []string `posture:"subset"`
|
|
|
|
// HasDirectories reports whether the process holds any directory FD.
|
|
//
|
|
// This is critical because a directory FD grants the ability to reach
|
|
// anything under it, and survives `chroot` and `pivot_root`.
|
|
HasDirectories bool
|
|
|
|
// Counts is the number of FDs in each class. Informational.
|
|
Counts map[string]int `posture:"info"`
|
|
|
|
// Links is the sorted set of readlink targets with inode numbers stripped,
|
|
// so that a failure report says what the unexpected FD actually was.
|
|
Links []string `posture:"info"`
|
|
}
|
|
|
|
// Attrs holds per-process attributes that do not group naturally elsewhere.
|
|
type Attrs struct {
|
|
// Umask is the process's file mode creation mask, e.g. "0022".
|
|
Umask string
|
|
|
|
// SessionLeader reports whether the process is its own session leader.
|
|
SessionLeader bool
|
|
|
|
// ProcessGroupLeader reports whether the process is its own group leader.
|
|
ProcessGroupLeader bool
|
|
|
|
// ChildOfRef reports whether the process's parent is the reference
|
|
// process. False under shim.
|
|
ChildOfRef bool
|
|
|
|
// Environ is the environment, sorted, with variables like
|
|
// `RUNSC_START_TIME_NANOS` and `GVISOR_ENFORCE_RELEASE` removed.
|
|
Environ []string
|
|
|
|
// Argv0 is argv[0], which `runsc` should set to "runsc-sandbox".
|
|
Argv0 string
|
|
|
|
// Limits maps resource names to normalized "<soft>/<hard>" strings, or to
|
|
// `LimitSameAsRef`.
|
|
Limits map[string]string
|
|
|
|
// UIDMap and GIDMap classify the process's user namespace ID mappings.
|
|
UIDMap string
|
|
GIDMap string
|
|
|
|
// ProcFileOwner is the ownership of the files under `/proc/<pid>`, which
|
|
// the kernel gives to root when a process is not dumpable.
|
|
// We use this as a proxy for whether `/proc/<pid>/mem` is reachable by
|
|
// the process's own user.
|
|
ProcFileOwner string
|
|
|
|
// Argv is the full command line. Informational only.
|
|
Argv []string `posture:"info"`
|
|
}
|
|
|
|
// Threads reports whether the process's threads share its posture.
|
|
type Threads struct {
|
|
// Uniform reports whether every thread's credentials, capabilities and
|
|
// seccomp state match process-wide values.
|
|
Uniform bool
|
|
|
|
// Divergences describes each mismatch found.
|
|
// Empty when `Uniform` is true.
|
|
Divergences []string
|
|
}
|
|
|
|
// PlatformCapSysPtrace contains which platforms need CAP_SYS_PTRACE.
|
|
// Platforms absent here are expected to not need it.
|
|
var PlatformCapSysPtrace = map[string]bool{
|
|
"systrap": true,
|
|
"ptrace": true,
|
|
}
|
|
|
|
// PlatformGoDebug contains the expected GODEBUG for each platform, if any.
|
|
var PlatformGoDebug = map[string]string{
|
|
"kvm": "asyncpreemptoff=1",
|
|
"slimvm": "asyncpreemptoff=1",
|
|
}
|
|
|
|
// Opts is the set of options to `Collect`.
|
|
type Opts struct {
|
|
// PID is the process to collect.
|
|
PID int
|
|
|
|
// RefPID is the reference process that posture is expressed relative to.
|
|
RefPID int
|
|
|
|
// NamespaceTargets maps a name to a path naming a namespace, such as
|
|
// {"container-netns": "/var/run/docker/netns/abc123"}.
|
|
NamespaceTargets map[string]string
|
|
|
|
// ProcRoot is the procfs mount to read through. Defaults to "/proc".
|
|
ProcRoot string
|
|
}
|
|
|
|
// Collect gathers the posture of `opts.PID`.
|
|
func Collect(opts Opts) (*Posture, error) {
|
|
if opts.PID <= 0 {
|
|
return nil, fmt.Errorf("invalid PID %d", opts.PID)
|
|
}
|
|
if opts.RefPID <= 0 {
|
|
return nil, fmt.Errorf("invalid reference PID %d", opts.RefPID)
|
|
}
|
|
procRoot := opts.ProcRoot
|
|
if procRoot == "" {
|
|
procRoot = "/proc"
|
|
}
|
|
p := &proc{root: procRoot, pid: opts.PID}
|
|
ref := &proc{root: procRoot, pid: opts.RefPID}
|
|
|
|
status, err := p.status()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading status: %w", err)
|
|
}
|
|
refStatus, err := ref.status()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading reference status: %w", err)
|
|
}
|
|
|
|
var posture Posture
|
|
if posture.Creds, err = collectCreds(status, refStatus); err != nil {
|
|
return nil, fmt.Errorf("collecting credentials: %w", err)
|
|
}
|
|
if posture.Caps, err = collectCaps(status); err != nil {
|
|
return nil, fmt.Errorf("collecting capabilities: %w", err)
|
|
}
|
|
if posture.Seccomp, err = collectSeccomp(status); err != nil {
|
|
return nil, fmt.Errorf("collecting seccomp state: %w", err)
|
|
}
|
|
if posture.Namespaces, err = collectNamespaces(p, ref, opts.NamespaceTargets); err != nil {
|
|
return nil, fmt.Errorf("collecting namespaces: %w", err)
|
|
}
|
|
if posture.FS, err = collectFilesystem(p, ref); err != nil {
|
|
return nil, fmt.Errorf("collecting filesystem view: %w", err)
|
|
}
|
|
if posture.FDs, err = collectFDs(p); err != nil {
|
|
return nil, fmt.Errorf("collecting FD table: %w", err)
|
|
}
|
|
if posture.Attrs, err = collectAttrs(p, ref, status, refStatus, posture.Namespaces.User); err != nil {
|
|
return nil, fmt.Errorf("collecting process attributes: %w", err)
|
|
}
|
|
if posture.Threads, err = collectThreads(p, status); err != nil {
|
|
return nil, fmt.Errorf("collecting per-thread posture: %w", err)
|
|
}
|
|
if posture.Children, err = collectChildren(p); err != nil {
|
|
return nil, fmt.Errorf("collecting children: %w", err)
|
|
}
|
|
return &posture, nil
|
|
}
|
|
|
|
// proc reads files out of one process's procfs directory.
|
|
type proc struct {
|
|
root string
|
|
pid int
|
|
}
|
|
|
|
func (p *proc) path(elems ...string) string {
|
|
return filepath.Join(append([]string{p.root, strconv.Itoa(p.pid)}, elems...)...)
|
|
}
|
|
|
|
func (p *proc) read(elems ...string) (string, error) {
|
|
b, err := os.ReadFile(p.path(elems...))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
// readNUL reads a `\x00`-separated procfs file (cmdline, environ).
|
|
func (p *proc) readNUL(elems ...string) ([]string, error) {
|
|
s, err := p.read(elems...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s = strings.TrimRight(s, "\x00")
|
|
if s == "" {
|
|
return nil, nil
|
|
}
|
|
return strings.Split(s, "\x00"), nil
|
|
}
|
|
|
|
// status parses the process's status file into a map.
|
|
func (p *proc) status() (map[string]string, error) {
|
|
return parseStatus(p.path("status"))
|
|
}
|
|
|
|
// taskStatus parses the status file of one of p's threads.
|
|
func (p *proc) taskStatus(tid string) (map[string]string, error) {
|
|
return parseStatus(p.path("task", tid, "status"))
|
|
}
|
|
|
|
func parseStatus(path string) (map[string]string, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m := make(map[string]string)
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
k, v, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
m[k] = strings.TrimSpace(v)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// idQuad parses a status line of four IDs (e.g. "Uid:\t0\t0\t0\t0").
|
|
func idQuad(status map[string]string, key string) ([4]int, error) {
|
|
var out [4]int
|
|
fields := strings.Fields(status[key])
|
|
if len(fields) != 4 {
|
|
return out, fmt.Errorf("malformed %s line %q", key, status[key])
|
|
}
|
|
for i, f := range fields {
|
|
v, err := strconv.Atoi(f)
|
|
if err != nil {
|
|
return out, fmt.Errorf("malformed %s line %q: %w", key, status[key], err)
|
|
}
|
|
out[i] = v
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// normalizeID pretty-prints an ID quad against `refQuad`.
|
|
func normalizeID(quad [4]int, refQuad [4]int) string {
|
|
uniform := quad[0] == quad[1] && quad[1] == quad[2] && quad[2] == quad[3]
|
|
name := func(id int) string {
|
|
switch id {
|
|
case refQuad[0]:
|
|
return IDSameAsRef
|
|
case overflowID:
|
|
return IDNobody
|
|
case 0:
|
|
return IDRoot
|
|
default:
|
|
return strconv.Itoa(id)
|
|
}
|
|
}
|
|
if uniform {
|
|
return name(quad[0])
|
|
}
|
|
parts := make([]string, 0, 4)
|
|
for _, id := range quad {
|
|
parts = append(parts, name(id))
|
|
}
|
|
return "real=" + parts[0] + " effective=" + parts[1] + " saved=" + parts[2] + " fs=" + parts[3]
|
|
}
|
|
|
|
func collectCreds(status, refStatus map[string]string) (Credentials, error) {
|
|
var c Credentials
|
|
uid, err := idQuad(status, "Uid")
|
|
if err != nil {
|
|
return c, err
|
|
}
|
|
refUID, err := idQuad(refStatus, "Uid")
|
|
if err != nil {
|
|
return c, fmt.Errorf("reference: %w", err)
|
|
}
|
|
gid, err := idQuad(status, "Gid")
|
|
if err != nil {
|
|
return c, err
|
|
}
|
|
refGID, err := idQuad(refStatus, "Gid")
|
|
if err != nil {
|
|
return c, fmt.Errorf("reference: %w", err)
|
|
}
|
|
c.UID = normalizeID(uid, refUID)
|
|
c.GID = normalizeID(gid, refGID)
|
|
|
|
groups := strings.Fields(status["Groups"])
|
|
refGroups := strings.Fields(refStatus["Groups"])
|
|
inRef := make(map[string]bool, len(refGroups))
|
|
for _, g := range refGroups {
|
|
inRef[g] = true
|
|
}
|
|
extra := make(map[string]bool)
|
|
for _, g := range groups {
|
|
if inRef[g] || g == strconv.Itoa(overflowID) {
|
|
continue
|
|
}
|
|
extra[g] = true
|
|
}
|
|
for g := range extra {
|
|
c.ExtraGroups = append(c.ExtraGroups, g)
|
|
}
|
|
sort.Strings(c.ExtraGroups)
|
|
|
|
sorted := append([]string(nil), groups...)
|
|
sort.Strings(sorted)
|
|
switch {
|
|
case len(sorted) == 0:
|
|
c.Groups = GroupsNone
|
|
case len(extra) == 0:
|
|
c.Groups = GroupsSameAsRef
|
|
default:
|
|
c.Groups = "gids:" + strings.Join(sorted, ",")
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// capNames maps a capability bit to its symbolic kernel name.
|
|
var capNames = func() map[int]string {
|
|
m := make(map[int]string)
|
|
for _, c := range capability.ListKnown() {
|
|
m[int(c)] = "CAP_" + strings.ToUpper(c.String())
|
|
}
|
|
return m
|
|
}()
|
|
|
|
// capName returns the symbolic name of the capability at bit position i.
|
|
// Unknown bits are rendered numerically rather than dropped: a capability this
|
|
// binary has never heard of appearing in a sandbox's permitted set is exactly
|
|
// the kind of thing that must show up in a diff.
|
|
func capName(i int) string {
|
|
if n, ok := capNames[i]; ok {
|
|
return n
|
|
}
|
|
return fmt.Sprintf("CAP_UNKNOWN_%d", i)
|
|
}
|
|
|
|
func capsFromMask(mask uint64) []string {
|
|
var out []string
|
|
for i := 0; i < 64; i++ {
|
|
if mask&(uint64(1)<<uint(i)) != 0 {
|
|
out = append(out, capName(i))
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func capSet(status map[string]string, key string) ([]string, error) {
|
|
v, ok := status[key]
|
|
if !ok {
|
|
return nil, fmt.Errorf("status has no %s line", key)
|
|
}
|
|
mask, err := strconv.ParseUint(strings.TrimSpace(v), 16, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("malformed %s line %q: %w", key, v, err)
|
|
}
|
|
return capsFromMask(mask), nil
|
|
}
|
|
|
|
func collectCaps(status map[string]string) (Capabilities, error) {
|
|
var c Capabilities
|
|
var err error
|
|
if c.Inheritable, err = capSet(status, "CapInh"); err != nil {
|
|
return c, err
|
|
}
|
|
if c.Permitted, err = capSet(status, "CapPrm"); err != nil {
|
|
return c, err
|
|
}
|
|
if c.Effective, err = capSet(status, "CapEff"); err != nil {
|
|
return c, err
|
|
}
|
|
if c.Bounding, err = capSet(status, "CapBnd"); err != nil {
|
|
return c, err
|
|
}
|
|
// CapAmb was added in Linux 4.something.
|
|
// Treat absence as an empty set, since this does imply
|
|
// "no ambient caps".
|
|
if _, ok := status["CapAmb"]; ok {
|
|
if c.Ambient, err = capSet(status, "CapAmb"); err != nil {
|
|
return c, err
|
|
}
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func collectSeccomp(status map[string]string) (Seccomp, error) {
|
|
var s Seccomp
|
|
var err error
|
|
if s.Mode, err = statusInt(status, "Seccomp", true); err != nil {
|
|
return s, err
|
|
}
|
|
// Seccomp_filters was added in Linux 5.9; older kernels do not report it.
|
|
if s.NumFilters, err = statusInt(status, "Seccomp_filters", false); err != nil {
|
|
return s, err
|
|
}
|
|
if s.NoNewPrivs, err = statusInt(status, "NoNewPrivs", true); err != nil {
|
|
return s, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// statusInt reads an integer status field.
|
|
// If `required` is false, missing means zero rather than an error.
|
|
func statusInt(status map[string]string, key string, required bool) (int, error) {
|
|
v, ok := status[key]
|
|
if !ok {
|
|
if required {
|
|
return 0, fmt.Errorf("status has no %s line", key)
|
|
}
|
|
return 0, nil
|
|
}
|
|
n, err := strconv.Atoi(strings.TrimSpace(v))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("malformed %s line %q: %w", key, v, err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// nsKinds are the namespace types collected, in the order they appear in
|
|
// `Namespaces`.
|
|
var nsKinds = []string{"mnt", "pid", "ipc", "uts", "net", "user", "cgroup", "time"}
|
|
|
|
// UnsupportedFeatures returns the posture-relevant things this kernel cannot
|
|
// report. Callers should skip the whole test when this function returns
|
|
// anything.
|
|
func UnsupportedFeatures() []string {
|
|
var missing []string
|
|
for _, kind := range nsKinds {
|
|
if _, err := nsIdentity(filepath.Join("/proc/self/ns", kind)); os.IsNotExist(err) {
|
|
missing = append(missing, kind+" namespace")
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
func collectNamespaces(p, ref *proc, targets map[string]string) (Namespaces, error) {
|
|
resolved := make(map[string]string, len(targets))
|
|
for name, path := range targets {
|
|
id, err := nsIdentity(path)
|
|
if err != nil {
|
|
return Namespaces{}, fmt.Errorf("resolving namespace target %q at %q: %w", name, path, err)
|
|
}
|
|
resolved[name] = id
|
|
}
|
|
|
|
classify := func(kind string) (string, error) {
|
|
id, err := nsIdentity(p.path("ns", kind))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
refID, refErr := nsIdentity(ref.path("ns", kind))
|
|
if refErr == nil && id == refID {
|
|
return NSInherited, nil
|
|
}
|
|
// Sort names so that a namespace matching two targets classifies
|
|
// deterministically.
|
|
names := make([]string, 0, len(resolved))
|
|
for name := range resolved {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
if resolved[name] == id {
|
|
return NSJoined(name), nil
|
|
}
|
|
}
|
|
return NSNew, nil
|
|
}
|
|
|
|
var ns Namespaces
|
|
dst := map[string]*string{
|
|
"mnt": &ns.Mnt, "pid": &ns.PID, "ipc": &ns.IPC, "uts": &ns.UTS,
|
|
"net": &ns.Net, "user": &ns.User, "cgroup": &ns.Cgroup, "time": &ns.Time,
|
|
}
|
|
for _, kind := range nsKinds {
|
|
v, err := classify(kind)
|
|
if err != nil {
|
|
return ns, fmt.Errorf("classifying %s namespace: %w", kind, err)
|
|
}
|
|
*dst[kind] = v
|
|
}
|
|
return ns, nil
|
|
}
|
|
|
|
// nsIdentity returns the dev/inode numbers for the namespace named by path.
|
|
// Used to match different paths referring to the same namespace.
|
|
func nsIdentity(path string) (string, error) {
|
|
var st unix.Stat_t
|
|
if err := unix.Stat(path, &st); err != nil {
|
|
return "", &os.PathError{Op: "stat", Path: path, Err: err}
|
|
}
|
|
return fmt.Sprintf("%d:%d", st.Dev, st.Ino), nil
|
|
}
|
|
|
|
// procfsSubmountRE matches the two names `runsc` gives the procfs instance it
|
|
// mounts inside the sandbox chroot.
|
|
var procfsSubmountRE = regexp.MustCompile(`/(sandbox-proc|host-proc)(/|$)`)
|
|
|
|
const procfsSubmountToken = "/<procfs>"
|
|
|
|
// normalizeMountpoint rewrites the procfs submount name to a stable string.
|
|
func normalizeMountpoint(path string) string {
|
|
return procfsSubmountRE.ReplaceAllString(path, procfsSubmountToken+"$2")
|
|
}
|
|
|
|
// mountFlags are the mount options that carry security implications.
|
|
var mountFlags = []string{"ro", "nosuid", "nodev", "noexec"}
|
|
|
|
// unescapeMount undoes the octal escaping procfs applies to path fields in
|
|
// mountinfo.
|
|
func unescapeMount(s string) string {
|
|
if !strings.Contains(s, `\`) {
|
|
return s
|
|
}
|
|
var b strings.Builder
|
|
for i := 0; i < len(s); {
|
|
if s[i] == '\\' && i+3 < len(s) {
|
|
if n, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
|
|
b.WriteByte(byte(n))
|
|
i += 4
|
|
continue
|
|
}
|
|
}
|
|
b.WriteByte(s[i])
|
|
i++
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// mountEntry is a parsed `/proc/<pid>/mountinfo` line.
|
|
type mountEntry struct {
|
|
mountpoint string
|
|
fstype string
|
|
flags string
|
|
}
|
|
|
|
func (m mountEntry) String() string {
|
|
return fmt.Sprintf("%s %s %s", m.mountpoint, m.fstype, m.flags)
|
|
}
|
|
|
|
func parseMountinfo(s string) ([]mountEntry, error) {
|
|
var out []mountEntry
|
|
for _, line := range strings.Split(s, "\n") {
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
// Fields up to the optional-field separator "-": mountID parentID
|
|
// major:minor rootWithinFS mountpoint options [optional...]
|
|
if len(fields) < 7 {
|
|
return nil, fmt.Errorf("malformed mountinfo line %q", line)
|
|
}
|
|
sep := -1
|
|
for i := 6; i < len(fields); i++ {
|
|
if fields[i] == "-" {
|
|
sep = i
|
|
break
|
|
}
|
|
}
|
|
if sep < 0 || sep+2 >= len(fields) {
|
|
return nil, fmt.Errorf("malformed mountinfo line %q: no separator", line)
|
|
}
|
|
var flags []string
|
|
opts := strings.Split(fields[5], ",")
|
|
for _, want := range mountFlags {
|
|
for _, got := range opts {
|
|
if got == want {
|
|
flags = append(flags, want)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
out = append(out, mountEntry{
|
|
mountpoint: normalizeMountpoint(unescapeMount(fields[4])),
|
|
fstype: fields[sep+1],
|
|
flags: strings.Join(flags, ","),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func collectFilesystem(p, ref *proc) (Filesystem, error) {
|
|
var fs Filesystem
|
|
|
|
rootPath := p.path("root")
|
|
if link, err := os.Readlink(rootPath); err == nil {
|
|
fs.RootLink = link
|
|
} else {
|
|
fs.RootLink = "unreadable: " + err.Error()
|
|
}
|
|
|
|
rootID, err := nsIdentity(rootPath)
|
|
if err != nil {
|
|
return fs, fmt.Errorf("stat %s: %w", rootPath, err)
|
|
}
|
|
refRootID, err := nsIdentity(ref.path("root"))
|
|
if err != nil {
|
|
return fs, fmt.Errorf("stat reference root: %w", err)
|
|
}
|
|
if rootID == refRootID {
|
|
fs.Root = RootSameAsRef
|
|
} else {
|
|
fs.Root = RootOwn
|
|
}
|
|
|
|
if fs.Entries, err = walkRoot(rootPath); err != nil {
|
|
return fs, fmt.Errorf("listing root: %w", err)
|
|
}
|
|
|
|
// The cwd link is resolved against the target's root, so compare its text
|
|
// with the root link's text to express cwd relative to that root.
|
|
cwd, err := os.Readlink(p.path("cwd"))
|
|
if err != nil {
|
|
return fs, fmt.Errorf("reading cwd: %w", err)
|
|
}
|
|
fs.Cwd = func() string {
|
|
if fs.RootLink == "" || fs.RootLink == "/" || strings.HasPrefix(fs.RootLink, "unreadable") {
|
|
return cwd
|
|
}
|
|
if cwd == fs.RootLink {
|
|
return "/"
|
|
}
|
|
if rest, ok := strings.CutPrefix(cwd, fs.RootLink+"/"); ok {
|
|
return "/" + rest
|
|
}
|
|
return cwd
|
|
}()
|
|
|
|
var statfs unix.Statfs_t
|
|
if err := unix.Statfs(rootPath, &statfs); err != nil {
|
|
return fs, fmt.Errorf("statfs %s: %w", rootPath, err)
|
|
}
|
|
fs.ReadOnly = statfs.Flags&unix.ST_RDONLY != 0
|
|
|
|
mountinfo, err := p.read("mountinfo")
|
|
if err != nil {
|
|
return fs, fmt.Errorf("reading mountinfo: %w", err)
|
|
}
|
|
entries, err := parseMountinfo(mountinfo)
|
|
if err != nil {
|
|
return fs, err
|
|
}
|
|
for _, e := range entries {
|
|
// Drop mounts nested underneath the minimal procfs submount.
|
|
if strings.Contains(e.mountpoint, procfsSubmountToken+"/") {
|
|
continue
|
|
}
|
|
fs.Mounts = append(fs.Mounts, e.String())
|
|
}
|
|
sort.Strings(fs.Mounts)
|
|
return fs, nil
|
|
}
|
|
|
|
// walkRoot lists the contents of `root` without crossing mountpoints.
|
|
func walkRoot(root string) ([]string, error) {
|
|
const maxRootEntries = 256
|
|
var rootStat unix.Stat_t
|
|
if err := unix.Stat(root, &rootStat); err != nil {
|
|
return nil, &os.PathError{Op: "stat", Path: root, Err: err}
|
|
}
|
|
|
|
var out []string
|
|
var walk func(dir, prefix string) error
|
|
walk = func(dir, prefix string) error {
|
|
ents, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
// runsc chmods the chroot's procfs root to 0111 on purpose.
|
|
if os.IsPermission(err) {
|
|
out = append(out, prefix+" [unreadable]")
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
for _, ent := range ents {
|
|
if len(out) >= maxRootEntries {
|
|
return nil
|
|
}
|
|
name := prefix + ent.Name()
|
|
full := filepath.Join(dir, ent.Name())
|
|
if !ent.IsDir() {
|
|
out = append(out, name)
|
|
continue
|
|
}
|
|
out = append(out, name+"/")
|
|
var st unix.Stat_t
|
|
if err := unix.Lstat(full, &st); err != nil {
|
|
continue
|
|
}
|
|
if st.Dev != rootStat.Dev {
|
|
continue // Do not descend into a separate mount.
|
|
}
|
|
if err := walk(full, name+"/"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if err := walk(root, ""); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(out) >= maxRootEntries {
|
|
out = append(out[:maxRootEntries], fmt.Sprintf("[truncated at %d entries]", maxRootEntries))
|
|
}
|
|
sort.Strings(out)
|
|
return out, nil
|
|
}
|
|
|
|
// inodeRE matches the inode number inside a readlink target such as
|
|
// "socket:[12345]" or "pipe:[678]".
|
|
var inodeRE = regexp.MustCompile(`\[\d+\]`)
|
|
|
|
// deletedSuffix is what the kernel appends to a readlink target whose file has
|
|
// been unlinked.
|
|
const deletedSuffix = " (deleted)"
|
|
|
|
func collectFDs(p *proc) (FDTable, error) {
|
|
var t FDTable
|
|
fdDir := p.path("fd")
|
|
ents, err := os.ReadDir(fdDir)
|
|
if err != nil {
|
|
return t, fmt.Errorf("reading %s: %w", fdDir, err)
|
|
}
|
|
|
|
classes := make(map[string]int)
|
|
links := make(map[string]bool)
|
|
for _, ent := range ents {
|
|
full := filepath.Join(fdDir, ent.Name())
|
|
link, err := os.Readlink(full)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue // The FD was closed while we were looking at it.
|
|
}
|
|
return t, fmt.Errorf("readlink %s: %w", full, err)
|
|
}
|
|
var st unix.Stat_t
|
|
mode := uint32(0)
|
|
if err := unix.Stat(full, &st); err == nil {
|
|
mode = st.Mode
|
|
}
|
|
class := classifyFD(link, mode)
|
|
classes[class]++
|
|
links[inodeRE.ReplaceAllString(link, "[N]")] = true
|
|
if class == FDDirectory {
|
|
t.HasDirectories = true
|
|
}
|
|
}
|
|
t.Counts = classes
|
|
for c := range classes {
|
|
t.Classes = append(t.Classes, c)
|
|
}
|
|
sort.Strings(t.Classes)
|
|
for l := range links {
|
|
t.Links = append(t.Links, l)
|
|
}
|
|
sort.Strings(t.Links)
|
|
return t, nil
|
|
}
|
|
|
|
// classifyFD reduces one FD to a normalized class.
|
|
// `link` is the readlink text. `mode` is the `st_mode` from `stat`'ing
|
|
// through the link, or `0` if that failed.
|
|
func classifyFD(link string, mode uint32) string {
|
|
switch {
|
|
case strings.HasPrefix(link, "socket:"):
|
|
return FDSocket
|
|
case strings.HasPrefix(link, "pipe:"):
|
|
return FDPipe
|
|
case strings.HasPrefix(link, "anon_inode:"):
|
|
name := strings.TrimPrefix(link, "anon_inode:")
|
|
name = strings.TrimSuffix(strings.TrimPrefix(name, "["), "]")
|
|
return FDAnonPrefix + strings.ReplaceAll(name, " ", "-")
|
|
case strings.HasPrefix(link, "/memfd:"):
|
|
name := strings.TrimSuffix(strings.TrimPrefix(link, "/memfd:"), deletedSuffix)
|
|
return FDMemfdPrefix + strings.TrimSpace(name)
|
|
case link == "/dev/null":
|
|
return FDNullDev
|
|
}
|
|
switch mode & unix.S_IFMT {
|
|
case unix.S_IFDIR:
|
|
return FDDirectory
|
|
case unix.S_IFCHR:
|
|
return FDCharDev + ":" + strings.TrimSuffix(link, deletedSuffix)
|
|
case unix.S_IFBLK:
|
|
return FDBlockDev
|
|
case unix.S_IFIFO:
|
|
return FDFifo
|
|
case unix.S_IFSOCK:
|
|
return FDSocket
|
|
case unix.S_IFLNK:
|
|
return FDSymlink
|
|
case unix.S_IFREG:
|
|
if strings.HasSuffix(link, deletedSuffix) {
|
|
return FDDeleted
|
|
}
|
|
return FDRegular
|
|
}
|
|
return FDUnknown
|
|
}
|
|
|
|
// limitKeys maps the procfs `limits` file's human-readable names to the short
|
|
// names used in `Attrs.Limits`.
|
|
var limitKeys = map[string]string{
|
|
"Max open files": "NOFILE",
|
|
"Max locked memory": "MEMLOCK",
|
|
}
|
|
|
|
// relativeLimits are the limits reported as `LimitSameAsRef` when they match
|
|
// the reference process's, rather than by value.
|
|
var relativeLimits = map[string]bool{
|
|
"NOFILE": true,
|
|
}
|
|
|
|
func parseLimits(s string) (map[string]string, error) {
|
|
out := make(map[string]string)
|
|
lines := strings.Split(s, "\n")
|
|
if len(lines) == 0 {
|
|
return nil, fmt.Errorf("empty limits file")
|
|
}
|
|
for _, line := range lines[1:] { // Skip header
|
|
for human, short := range limitKeys {
|
|
rest, ok := strings.CutPrefix(line, human)
|
|
if !ok {
|
|
continue
|
|
}
|
|
fields := strings.Fields(rest)
|
|
if len(fields) < 2 {
|
|
return nil, fmt.Errorf("malformed limits line %q", line)
|
|
}
|
|
out[short] = fields[0] + "/" + fields[1]
|
|
}
|
|
}
|
|
for _, short := range limitKeys {
|
|
if _, ok := out[short]; !ok {
|
|
return nil, fmt.Errorf("limits file has no %s entry", short)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ignoredEnvRegExp matches the start-time env variable `runsc` uses.
|
|
var ignoredEnvRegExp = regexp.MustCompile(`^(RUNSC_START_TIME_NANOS=\d+|GVISOR_ENFORCE_RELEASE=.*)$`)
|
|
|
|
func collectAttrs(p, ref *proc, status, refStatus map[string]string, userNS string) (Attrs, error) {
|
|
var a Attrs
|
|
|
|
umask, ok := status["Umask"]
|
|
if !ok {
|
|
return a, fmt.Errorf("status has no Umask line")
|
|
}
|
|
a.Umask = umask
|
|
|
|
stat, err := p.read("stat")
|
|
if err != nil {
|
|
return a, fmt.Errorf("reading stat: %w", err)
|
|
}
|
|
st, err := parseStat(stat)
|
|
if err != nil {
|
|
return a, err
|
|
}
|
|
a.SessionLeader = st.session == p.pid
|
|
a.ProcessGroupLeader = st.pgrp == p.pid
|
|
a.ChildOfRef = st.ppid == ref.pid
|
|
|
|
env, err := p.readNUL("environ")
|
|
if err != nil {
|
|
return a, fmt.Errorf("reading environ: %w", err)
|
|
}
|
|
for _, e := range env {
|
|
if ignoredEnvRegExp.MatchString(e) {
|
|
continue
|
|
}
|
|
a.Environ = append(a.Environ, e)
|
|
}
|
|
sort.Strings(a.Environ)
|
|
|
|
if a.Argv, err = p.readNUL("cmdline"); err != nil {
|
|
return a, fmt.Errorf("reading cmdline: %w", err)
|
|
}
|
|
if len(a.Argv) == 0 {
|
|
return a, fmt.Errorf("empty cmdline: process %d is a zombie or exited", p.pid)
|
|
}
|
|
a.Argv0 = a.Argv[0]
|
|
limitsRaw, err := p.read("limits")
|
|
if err != nil {
|
|
return a, fmt.Errorf("reading limits: %w", err)
|
|
}
|
|
limits, err := parseLimits(limitsRaw)
|
|
if err != nil {
|
|
return a, err
|
|
}
|
|
refLimitsRaw, err := ref.read("limits")
|
|
if err != nil {
|
|
return a, fmt.Errorf("reading reference limits: %w", err)
|
|
}
|
|
refLimits, err := parseLimits(refLimitsRaw)
|
|
if err != nil {
|
|
return a, fmt.Errorf("reference: %w", err)
|
|
}
|
|
a.Limits = make(map[string]string, len(limits))
|
|
for k, v := range limits {
|
|
switch {
|
|
case relativeLimits[k] && v == refLimits[k]:
|
|
a.Limits[k] = LimitSameAsRef
|
|
default:
|
|
a.Limits[k] = v
|
|
}
|
|
}
|
|
refUID, err := idQuad(refStatus, "Uid")
|
|
if err != nil {
|
|
return a, fmt.Errorf("reference: %w", err)
|
|
}
|
|
refGID, err := idQuad(refStatus, "Gid")
|
|
if err != nil {
|
|
return a, fmt.Errorf("reference: %w", err)
|
|
}
|
|
if a.UIDMap, err = collectIDMap(p, ref, "uid_map", userNS, refUID[0]); err != nil {
|
|
return a, err
|
|
}
|
|
if a.GIDMap, err = collectIDMap(p, ref, "gid_map", userNS, refGID[0]); err != nil {
|
|
return a, err
|
|
}
|
|
|
|
if a.ProcFileOwner, err = collectProcFileOwner(p, status); err != nil {
|
|
return a, err
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// statFields holds the fields of a procfs stat file that this package uses.
|
|
type statFields struct {
|
|
ppid int
|
|
pgrp int
|
|
session int
|
|
}
|
|
|
|
// parseStat parses a procfs stat file.
|
|
func parseStat(s string) (statFields, error) {
|
|
var out statFields
|
|
idx := strings.LastIndex(s, ")")
|
|
if idx < 0 {
|
|
return out, fmt.Errorf("malformed stat file %q", s)
|
|
}
|
|
// Fields after the comm: state ppid pgrp session ...
|
|
fields := strings.Fields(s[idx+1:])
|
|
if len(fields) < 4 {
|
|
return out, fmt.Errorf("malformed stat file %q: only %d fields after comm", s, len(fields))
|
|
}
|
|
var err error
|
|
if out.ppid, err = strconv.Atoi(fields[1]); err != nil {
|
|
return out, fmt.Errorf("malformed ppid in stat file %q: %w", s, err)
|
|
}
|
|
if out.pgrp, err = strconv.Atoi(fields[2]); err != nil {
|
|
return out, fmt.Errorf("malformed pgrp in stat file %q: %w", s, err)
|
|
}
|
|
if out.session, err = strconv.Atoi(fields[3]); err != nil {
|
|
return out, fmt.Errorf("malformed session in stat file %q: %w", s, err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// idMapEntry is one line of a `uid_map` or `gid_map` file.
|
|
type idMapEntry struct {
|
|
inside int
|
|
outside int
|
|
count int
|
|
}
|
|
|
|
func parseIDMap(s string) ([]idMapEntry, error) {
|
|
var out []idMapEntry
|
|
for _, line := range strings.Split(s, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) == 0 {
|
|
continue
|
|
}
|
|
if len(fields) != 3 {
|
|
return nil, fmt.Errorf("malformed ID map line %q", line)
|
|
}
|
|
var e idMapEntry
|
|
var err error
|
|
if e.inside, err = strconv.Atoi(fields[0]); err != nil {
|
|
return nil, fmt.Errorf("malformed ID map line %q: %w", line, err)
|
|
}
|
|
if e.outside, err = strconv.Atoi(fields[1]); err != nil {
|
|
return nil, fmt.Errorf("malformed ID map line %q: %w", line, err)
|
|
}
|
|
if e.count, err = strconv.Atoi(fields[2]); err != nil {
|
|
return nil, fmt.Errorf("malformed ID map line %q: %w", line, err)
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// collectIDMap classifies a process's `uid_map` or `gid_map`.
|
|
func collectIDMap(p, ref *proc, file, userNS string, refID int) (string, error) {
|
|
raw, err := p.read(file)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading %s: %w", file, err)
|
|
}
|
|
entries, err := parseIDMap(raw)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parsing %s: %w", file, err)
|
|
}
|
|
if len(entries) == 0 {
|
|
return MapEmpty, nil
|
|
}
|
|
if userNS == NSInherited {
|
|
return MapSameAsRef, nil
|
|
}
|
|
|
|
refRaw, err := ref.read(file)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading reference %s: %w", file, err)
|
|
}
|
|
refEntries, err := parseIDMap(refRaw)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parsing reference %s: %w", file, err)
|
|
}
|
|
|
|
if isIdentityOf(entries, refEntries) {
|
|
return MapIdentityOfRef, nil
|
|
}
|
|
if len(entries) == 1 && entries[0].inside == overflowID && entries[0].count == 1 {
|
|
switch entries[0].outside {
|
|
case overflowID:
|
|
return MapNobodyToNobody, nil
|
|
case refID:
|
|
return MapNobodyToRef, nil
|
|
}
|
|
}
|
|
parts := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
parts = append(parts, fmt.Sprintf("%d %d %d", e.inside, e.outside, e.count))
|
|
}
|
|
return "raw:" + strings.Join(parts, ";"), nil
|
|
}
|
|
|
|
// isIdentityOf reports whether entries maps every ID to itself, over exactly
|
|
// the ranges that `ref` makes available.
|
|
func isIdentityOf(entries, ref []idMapEntry) bool {
|
|
if len(entries) != len(ref) || len(entries) == 0 {
|
|
return false
|
|
}
|
|
for _, e := range entries {
|
|
if e.inside != e.outside {
|
|
return false
|
|
}
|
|
}
|
|
key := func(es []idMapEntry) []string {
|
|
out := make([]string, 0, len(es))
|
|
for _, e := range es {
|
|
out = append(out, fmt.Sprintf("%d+%d", e.inside, e.count))
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
a, b := key(entries), key(ref)
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func collectProcFileOwner(p *proc, status map[string]string) (string, error) {
|
|
// We use a file under `/proc/<pid>` here, not `/proc/<pid>` itself.
|
|
// The kernel exempts the `/proc/<pid>` directory from the rule that makes
|
|
// non-dumpable tasks procfs files owned as root.
|
|
path := p.path("status")
|
|
var st unix.Stat_t
|
|
if err := unix.Stat(path, &st); err != nil {
|
|
return "", fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
uid, err := idQuad(status, "Uid")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
gid, err := idQuad(status, "Gid")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
switch {
|
|
case int(st.Uid) == uid[1] && int(st.Gid) == gid[1]:
|
|
return OwnerSameAsProcess, nil
|
|
case st.Uid == 0 && st.Gid == 0:
|
|
return OwnerRoot, nil
|
|
default:
|
|
return fmt.Sprintf("uid=%d gid=%d", st.Uid, st.Gid), nil
|
|
}
|
|
}
|
|
|
|
// threadFields are the status fields that must match on *every* thread.
|
|
var threadFields = []string{
|
|
"Uid", "Gid",
|
|
"CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb",
|
|
"NoNewPrivs", "Seccomp", "Seccomp_filters",
|
|
}
|
|
|
|
func collectThreads(p *proc, status map[string]string) (Threads, error) {
|
|
var t Threads
|
|
taskDir := p.path("task")
|
|
ents, err := os.ReadDir(taskDir)
|
|
if err != nil {
|
|
return t, fmt.Errorf("reading %s: %w", taskDir, err)
|
|
}
|
|
seen := make(map[string]bool)
|
|
var count int
|
|
for _, ent := range ents {
|
|
ts, err := p.taskStatus(ent.Name())
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue // Thread exited while we were looking at it.
|
|
}
|
|
return t, fmt.Errorf("reading thread %s status: %w", ent.Name(), err)
|
|
}
|
|
count++
|
|
comm := ts["Name"]
|
|
for _, f := range threadFields {
|
|
want, haveWant := status[f]
|
|
got, haveGot := ts[f]
|
|
if haveWant != haveGot || want != got {
|
|
seen[fmt.Sprintf("thread %q: %s = %q, process has %q", comm, f, got, want)] = true
|
|
}
|
|
}
|
|
}
|
|
if count == 0 {
|
|
return t, fmt.Errorf("no threads found in %s", taskDir)
|
|
}
|
|
for d := range seen {
|
|
t.Divergences = append(t.Divergences, d)
|
|
}
|
|
sort.Strings(t.Divergences)
|
|
t.Uniform = len(t.Divergences) == 0
|
|
return t, nil
|
|
}
|
|
|
|
func collectChildren(p *proc) (map[string]int, error) {
|
|
out := make(map[string]int)
|
|
taskDir := p.path("task")
|
|
ents, err := os.ReadDir(taskDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading %s: %w", taskDir, err)
|
|
}
|
|
for _, ent := range ents {
|
|
raw, err := os.ReadFile(filepath.Join(taskDir, ent.Name(), "children"))
|
|
if err != nil {
|
|
continue // This field is best-effort anyway
|
|
}
|
|
for f := range strings.FieldsSeq(string(raw)) {
|
|
comm, err := os.ReadFile(filepath.Join(p.root, f, "comm"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out[strings.TrimSpace(string(comm))]++
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|