Watch
1
0
Fork
You've already forked RedFlag
0

processes: read the capabilities the readme already claimed

CapEff was never collected — "sockets, capabilities, namespaces" meant the
collection limits, and a reader has no way to know that. The mask is read on
the list scan and expanded to names only on drill-down, because a fully
privileged process holds all 41 and nobody reads that on 300 rows.

Docker's documented default mask a80425fb decodes to exactly its fourteen.
This commit is contained in:
Fimeg 2026-08-31 22:44:18 -04:00
commit 102ea23058
5 changed files with 143 additions and 2 deletions

View file

@ -0,0 +1,53 @@
package system
import (
"strconv"
"strings"
)
// capabilityNames is indexed by capability bit. CAP_CHECKPOINT_RESTORE (40) is
// CAP_LAST_CAP on Linux 5.9 and later; a bit past the end of this table is
// reported as CAP_<n> rather than dropped, so a newer kernel stays legible.
var capabilityNames = []string{
"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH", "CAP_FOWNER",
"CAP_FSETID", "CAP_KILL", "CAP_SETGID", "CAP_SETUID",
"CAP_SETPCAP", "CAP_LINUX_IMMUTABLE", "CAP_NET_BIND_SERVICE", "CAP_NET_BROADCAST",
"CAP_NET_ADMIN", "CAP_NET_RAW", "CAP_IPC_LOCK", "CAP_IPC_OWNER",
"CAP_SYS_MODULE", "CAP_SYS_RAWIO", "CAP_SYS_CHROOT", "CAP_SYS_PTRACE",
"CAP_SYS_PACCT", "CAP_SYS_ADMIN", "CAP_SYS_BOOT", "CAP_SYS_NICE",
"CAP_SYS_RESOURCE", "CAP_SYS_TIME", "CAP_SYS_TTY_CONFIG", "CAP_MKNOD",
"CAP_LEASE", "CAP_AUDIT_WRITE", "CAP_AUDIT_CONTROL", "CAP_SETFCAP",
"CAP_MAC_OVERRIDE", "CAP_MAC_ADMIN", "CAP_SYSLOG", "CAP_WAKE_ALARM",
"CAP_BLOCK_SUSPEND", "CAP_AUDIT_READ", "CAP_PERFMON", "CAP_BPF",
"CAP_CHECKPOINT_RESTORE",
}
// decodeCapabilities expands a CapEff hex mask from /proc/[pid]/status into
// capability names. The mask is 64 bits of hex with no 0x prefix.
//
// The list scan carries the mask; only drill-down expands it. Fully privileged
// processes hold every bit, and 41 strings on every row of a 300-process
// inventory is payload nobody reads.
func decodeCapabilities(mask string) []string {
mask = strings.TrimSpace(mask)
if mask == "" {
return nil
}
bits, err := strconv.ParseUint(mask, 16, 64)
if err != nil || bits == 0 {
return nil
}
var held []string
for bit := 0; bit < 64; bit++ {
if bits&(1<<uint(bit)) == 0 {
continue
}
if bit < len(capabilityNames) {
held = append(held, capabilityNames[bit])
continue
}
held = append(held, "CAP_"+strconv.Itoa(bit))
}
return held
}

View file

@ -0,0 +1,53 @@
package system
import (
"reflect"
"testing"
)
func TestDecodeCapabilities(t *testing.T) {
cases := []struct {
name string
mask string
want []string
}{
{name: "unprivileged", mask: "0000000000000000", want: nil},
{name: "empty", mask: "", want: nil},
{name: "whitespace is trimmed", mask: " 0000000000000001 ", want: []string{"CAP_CHOWN"}},
{name: "single low bit", mask: "0000000000000001", want: []string{"CAP_CHOWN"}},
{name: "net bind service", mask: "0000000000000400", want: []string{"CAP_NET_BIND_SERVICE"}},
{name: "sys admin", mask: "0000000000200000", want: []string{"CAP_SYS_ADMIN"}},
{
name: "a container's default docker set",
mask: "00000000a80425fb",
want: []string{
"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID",
"CAP_KILL", "CAP_SETGID", "CAP_SETUID", "CAP_SETPCAP",
"CAP_NET_BIND_SERVICE", "CAP_NET_RAW", "CAP_SYS_CHROOT",
"CAP_MKNOD", "CAP_AUDIT_WRITE", "CAP_SETFCAP",
},
},
{name: "beyond the known table stays legible", mask: "0000020000000000", want: []string{"CAP_41"}},
{name: "not hex", mask: "not-a-mask", want: nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := decodeCapabilities(tc.mask)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("decodeCapabilities(%q)\n got: %v\nwant: %v", tc.mask, got, tc.want)
}
})
}
}
func TestDecodeCapabilitiesFullRootSet(t *testing.T) {
// Bits 0..40 — every capability up to CAP_LAST_CAP on Linux 5.9+.
got := decodeCapabilities("000001ffffffffff")
if len(got) != len(capabilityNames) {
t.Fatalf("full mask decoded %d capabilities, want %d", len(got), len(capabilityNames))
}
if got[0] != "CAP_CHOWN" || got[len(got)-1] != "CAP_CHECKPOINT_RESTORE" {
t.Fatalf("full mask bounds wrong: first=%s last=%s", got[0], got[len(got)-1])
}
}

View file

@ -50,6 +50,10 @@ type FullProcess struct {
// Ownership — cgroup, systemd unit, container. Inlined into the JSON.
ProcessOwner
// Effective capability mask from /proc/[pid]/status. Decoded into names on
// drill-down only — see Capabilities below.
CapabilitiesEffective string `json:"capabilities_effective,omitempty"`
// Disk I/O (from /proc/[pid]/io, may be empty for other users' processes)
DiskBytesRead uint64 `json:"disk_bytes_read,omitempty"`
DiskBytesWritten uint64 `json:"disk_bytes_written,omitempty"`
@ -62,6 +66,7 @@ type FullProcess struct {
MemoryMap []ProcessMemoryMap `json:"memory_map,omitempty"`
Namespaces []ProcessNamespace `json:"namespaces,omitempty"`
ListeningPorts []ProcessListeningPort `json:"listening_ports,omitempty"`
Capabilities []string `json:"capabilities,omitempty"` // effective set, decoded
}
// Related data types — stored as JSONB per relation in the database.

View file

@ -148,6 +148,11 @@ func readFullProc(pid int, totalCPU, memTotal uint64, passwd, group map[uint32]s
if len(parts) >= 2 {
proc.Threads = atoi(parts[1])
}
case bytes.HasPrefix(line, []byte("CapEff:")):
parts := strings.Fields(string(line))
if len(parts) >= 2 {
proc.CapabilitiesEffective = parts[1]
}
}
}
}
@ -314,6 +319,9 @@ func getProcessDetail(pid int, caps ProcessCaps) (*FullProcess, error) {
// Namespaces from /proc/[pid]/ns/
proc.Namespaces = readNamespaces(pidStr, caps.MaxNamespaces)
// Effective capabilities, expanded from the mask the list scan already read
proc.Capabilities = decodeCapabilities(proc.CapabilitiesEffective)
// Listening ports: correlate socket inodes from fd walk with /proc/net/tcp
proc.ListeningPorts = readListeningPorts(pid, socketInodes, caps.MaxListeningPorts)