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:
parent
23b2d5c3c2
commit
102ea23058
5 changed files with 143 additions and 2 deletions
|
|
@ -41,7 +41,7 @@ On drill-down (clicking a process row):
|
||||||
|
|
||||||
Reads `/proc/[pid]/stat`, `/proc/[pid]/status`, `/proc/[pid]/exe`, `/proc/[pid]/cmdline`, `/proc/[pid]/cwd`, `/proc/[pid]/cgroup`, `/proc/[pid]/io` for every PID. No related data collected at this stage.
|
Reads `/proc/[pid]/stat`, `/proc/[pid]/status`, `/proc/[pid]/exe`, `/proc/[pid]/cmdline`, `/proc/[pid]/cwd`, `/proc/[pid]/cgroup`, `/proc/[pid]/io` for every PID. No related data collected at this stage.
|
||||||
|
|
||||||
**Fields (25+):** PID, Name, Path, Cmdline, Cwd, State, UID, GID, EUID, EGID, User, Group, TTY, TTYName, CPUSecondsUser, CPUSecondsSystem, CPUPercent, RSSBytes, VMSBytes, MemPercent, Threads, Nice, StartTimeSeconds, ParentPID, ProcessGroupID, ElevationStatus, OnDisk, DiskBytesRead, DiskBytesWritten, Cgroup, Unit, ContainerID, ContainerRuntime
|
**Fields (25+):** PID, Name, Path, Cmdline, Cwd, State, UID, GID, EUID, EGID, User, Group, TTY, TTYName, CPUSecondsUser, CPUSecondsSystem, CPUPercent, RSSBytes, VMSBytes, MemPercent, Threads, Nice, StartTimeSeconds, ParentPID, ProcessGroupID, ElevationStatus, OnDisk, DiskBytesRead, DiskBytesWritten, Cgroup, Unit, ContainerID, ContainerRuntime, CapabilitiesEffective
|
||||||
|
|
||||||
### Drill-Down (`GetProcessDetail`)
|
### Drill-Down (`GetProcessDetail`)
|
||||||
|
|
||||||
|
|
@ -56,6 +56,7 @@ Adds related data from a single `/proc/[pid]/fd/` walk (consolidated from three
|
||||||
| Memory map | `/proc/[pid]/maps` | `max_memory_map` (default 2000) |
|
| Memory map | `/proc/[pid]/maps` | `max_memory_map` (default 2000) |
|
||||||
| Namespaces | `/proc/[pid]/ns/` symlinks | `max_namespaces` (default 50) |
|
| Namespaces | `/proc/[pid]/ns/` symlinks | `max_namespaces` (default 50) |
|
||||||
| Listening ports | Socket inode correlation with `/proc/net/tcp` | `max_listening_ports` (default 100) |
|
| Listening ports | Socket inode correlation with `/proc/net/tcp` | `max_listening_ports` (default 100) |
|
||||||
|
| Capabilities | `CapEff` mask read during the list scan, decoded into names here | — |
|
||||||
|
|
||||||
### Key Implementation Detail: Ownership Attribution
|
### Key Implementation Detail: Ownership Attribution
|
||||||
|
|
||||||
|
|
@ -93,6 +94,26 @@ Kernel threads have cgroup `/` and stay unattributed, correctly — they have no
|
||||||
container, and no package. On a 321-process Arch desktop that is 172 of them, and every
|
container, and no package. On a 321-process Arch desktop that is 172 of them, and every
|
||||||
userland process attributes.
|
userland process attributes.
|
||||||
|
|
||||||
|
### Key Implementation Detail: Effective Capabilities
|
||||||
|
|
||||||
|
`CapEff` in `/proc/[pid]/status` is the mask that decides what a process may actually do
|
||||||
|
to the machine — mount filesystems, load modules, trace another process, rewrite the
|
||||||
|
network stack. UID alone does not answer that: a non-root process can hold
|
||||||
|
`CAP_NET_ADMIN`, and a root process in a container usually holds far less than the full
|
||||||
|
set.
|
||||||
|
|
||||||
|
The mask is read during the list scan, because it costs nothing — the status file is
|
||||||
|
already open. It is expanded into names only on drill-down: fully privileged processes
|
||||||
|
hold all 41, and 41 strings on every row of a 300-process inventory is payload nobody
|
||||||
|
reads. `decodeCapabilities` is a pure function over the hex mask, so it is fixture-tested
|
||||||
|
without a machine.
|
||||||
|
|
||||||
|
A bit past `CAP_LAST_CAP` is reported as `CAP_<n>` rather than dropped. A newer kernel
|
||||||
|
than this table should produce an unfamiliar name, not silence.
|
||||||
|
|
||||||
|
Verification against a known constant: Docker's default container mask, `a80425fb`,
|
||||||
|
decodes to exactly the fourteen capabilities Docker documents itself as granting.
|
||||||
|
|
||||||
### Key Implementation Detail: Socket Inode Correlation
|
### Key Implementation Detail: Socket Inode Correlation
|
||||||
|
|
||||||
Listening ports are per-process, not system-wide. The scanner collects socket inodes from `/proc/[pid]/fd/` symlinks (`socket:[12345]`), then matches them against inode numbers in `/proc/net/tcp` and `/proc/net/tcp6`. Only LISTEN state (0A) entries whose inode matches a process socket are included.
|
Listening ports are per-process, not system-wide. The scanner collects socket inodes from `/proc/[pid]/fd/` symlinks (`socket:[12345]`), then matches them against inode numbers in `/proc/net/tcp` and `/proc/net/tcp6`. Only LISTEN state (0A) entries whose inode matches a process socket are included.
|
||||||
|
|
@ -135,6 +156,7 @@ Data collection limits are server-controlled via `ProcessExplorerConfig` (stored
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `agent/internal/system/process_detail.go` | Types: `FullProcess`, `ProcessOpenFile`, `ProcessOpenSocket`, `ProcessOpenPipe`, `ProcessMemoryMap`, `ProcessNamespace`, `ProcessListeningPort`, `ProcessCaps`, `FullProcessSnapshot` |
|
| `agent/internal/system/process_detail.go` | Types: `FullProcess`, `ProcessOpenFile`, `ProcessOpenSocket`, `ProcessOpenPipe`, `ProcessMemoryMap`, `ProcessNamespace`, `ProcessListeningPort`, `ProcessCaps`, `FullProcessSnapshot` |
|
||||||
| `agent/internal/system/process_owner.go` | `ProcessOwner` and `parseCgroupOwner` — cgroup attribution, platform-independent and fixture-tested |
|
| `agent/internal/system/process_owner.go` | `ProcessOwner` and `parseCgroupOwner` — cgroup attribution, platform-independent and fixture-tested |
|
||||||
|
| `agent/internal/system/process_capabilities.go` | `decodeCapabilities` and the capability-bit table — platform-independent and fixture-tested |
|
||||||
| `agent/internal/system/process_detail_linux.go` | Linux `/proc` reader: `getFullProcessSnapshot()`, `getProcessDetail()`, `walkProcFD()`, `readListeningPorts()`, `parseHexAddr()` |
|
| `agent/internal/system/process_detail_linux.go` | Linux `/proc` reader: `getFullProcessSnapshot()`, `getProcessDetail()`, `walkProcFD()`, `readListeningPorts()`, `parseHexAddr()` |
|
||||||
| `agent/internal/system/process_detail_other.go` | Stub for non-Linux platforms |
|
| `agent/internal/system/process_detail_other.go` | Stub for non-Linux platforms |
|
||||||
| `agent/internal/handlers/processes.go` | `HandleScanProcesses` — command handler |
|
| `agent/internal/handlers/processes.go` | `HandleScanProcesses` — command handler |
|
||||||
|
|
@ -172,4 +194,4 @@ Data collection limits are server-controlled via `ProcessExplorerConfig` (stored
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Added: 2026-06-10 · ownership attribution 2026-08-31*
|
*Added: 2026-06-10 · ownership attribution and effective capabilities 2026-08-31*
|
||||||
|
|
|
||||||
53
agent/internal/system/process_capabilities.go
Normal file
53
agent/internal/system/process_capabilities.go
Normal 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
|
||||||
|
}
|
||||||
53
agent/internal/system/process_capabilities_test.go
Normal file
53
agent/internal/system/process_capabilities_test.go
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,10 @@ type FullProcess struct {
|
||||||
// Ownership — cgroup, systemd unit, container. Inlined into the JSON.
|
// Ownership — cgroup, systemd unit, container. Inlined into the JSON.
|
||||||
ProcessOwner
|
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)
|
// Disk I/O (from /proc/[pid]/io, may be empty for other users' processes)
|
||||||
DiskBytesRead uint64 `json:"disk_bytes_read,omitempty"`
|
DiskBytesRead uint64 `json:"disk_bytes_read,omitempty"`
|
||||||
DiskBytesWritten uint64 `json:"disk_bytes_written,omitempty"`
|
DiskBytesWritten uint64 `json:"disk_bytes_written,omitempty"`
|
||||||
|
|
@ -62,6 +66,7 @@ type FullProcess struct {
|
||||||
MemoryMap []ProcessMemoryMap `json:"memory_map,omitempty"`
|
MemoryMap []ProcessMemoryMap `json:"memory_map,omitempty"`
|
||||||
Namespaces []ProcessNamespace `json:"namespaces,omitempty"`
|
Namespaces []ProcessNamespace `json:"namespaces,omitempty"`
|
||||||
ListeningPorts []ProcessListeningPort `json:"listening_ports,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.
|
// Related data types — stored as JSONB per relation in the database.
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,11 @@ func readFullProc(pid int, totalCPU, memTotal uint64, passwd, group map[uint32]s
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
proc.Threads = atoi(parts[1])
|
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/
|
// Namespaces from /proc/[pid]/ns/
|
||||||
proc.Namespaces = readNamespaces(pidStr, caps.MaxNamespaces)
|
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
|
// Listening ports: correlate socket inodes from fd walk with /proc/net/tcp
|
||||||
proc.ListeningPorts = readListeningPorts(pid, socketInodes, caps.MaxListeningPorts)
|
proc.ListeningPorts = readListeningPorts(pid, socketInodes, caps.MaxListeningPorts)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue