Bounded operations instead of a shell (ADR-0011): service.status, service.restart and logs.query run systemctl/journalctl from a fixed allow-list with no shell, a minimal environment, validated unit names and the unit as the derived policy target; logs mask e-mail and IP addresses and credential-like values by default. host.disk.usage, host.memory.status and host.process.list read the kernel directly and never read command lines. browser.navigate/read/links fetch through the SSRF-safe client, run no scripts, refuse cross-host redirects, use the host as the policy target and deliver content as low-trust observations. Writing browser operations are designed but not registered. The test harness takes a fake command runner so no test touches a real unit.
385 lines
15 KiB
Go
385 lines
15 KiB
Go
// Package host provides typed, bounded host and service capabilities:
|
|
// disk and memory status, a process list without command lines, systemd
|
|
// unit status and restart, and journal queries with personal data scrubbed.
|
|
// Every operation runs a fixed argument vector without a shell, validates
|
|
// unit names, bounds time and output, and says what it changes. There is no
|
|
// general command capability here, and none can be added at runtime.
|
|
package host
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/andydixon/noema/internal/capabilities"
|
|
)
|
|
|
|
// Runner executes one program with arguments and no shell. The daemon uses
|
|
// ExecRunner; tests substitute a fake so no real unit is touched.
|
|
type Runner interface {
|
|
Run(ctx context.Context, name string, args ...string) ([]byte, error)
|
|
}
|
|
|
|
// ExecRunner runs programs from a fixed set of absolute paths with a minimal
|
|
// environment and a bounded output.
|
|
type ExecRunner struct {
|
|
MaxOutput int // bytes kept from stdout (default 256 KiB)
|
|
}
|
|
|
|
// allowedPrograms are the only binaries a host capability may start.
|
|
var allowedPrograms = map[string]bool{"systemctl": true, "journalctl": true}
|
|
|
|
// Run implements Runner.
|
|
func (e ExecRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
if !allowedPrograms[name] {
|
|
return nil, fmt.Errorf("host: %s is not an allowed program", name)
|
|
}
|
|
path, err := exec.LookPath(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("host: %s not available: %w", name, err)
|
|
}
|
|
max := e.MaxOutput
|
|
if max <= 0 {
|
|
max = 256 << 10
|
|
}
|
|
cmd := exec.CommandContext(ctx, path, args...) // #nosec G204 -- program from a fixed allow-list; arguments validated by each capability
|
|
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C", "SYSTEMD_PAGER=", "SYSTEMD_COLORS=0"}
|
|
var out limited
|
|
out.max = max
|
|
var stderr limited
|
|
stderr.max = 4096
|
|
cmd.Stdout, cmd.Stderr = &out, &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return out.Bytes(), errors.New(msg)
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
type limited struct {
|
|
bytes.Buffer
|
|
max int
|
|
}
|
|
|
|
func (l *limited) Write(p []byte) (int, error) {
|
|
if room := l.max - l.Len(); room > 0 {
|
|
if len(p) > room {
|
|
l.Buffer.Write(p[:room])
|
|
} else {
|
|
l.Buffer.Write(p)
|
|
}
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
// Options configure the host capabilities.
|
|
type Options struct {
|
|
Runner Runner
|
|
ProcRoot string // default /proc
|
|
}
|
|
|
|
var unitRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9@_.:-]{0,127}$`)
|
|
|
|
// Unit validates a systemd unit name and appends ".service" when no type is given.
|
|
func Unit(name string) (string, error) {
|
|
name = strings.TrimSpace(name)
|
|
if !unitRe.MatchString(name) || strings.Contains(name, "..") {
|
|
return "", errors.New("invalid unit name")
|
|
}
|
|
if !strings.Contains(name, ".") {
|
|
name += ".service"
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
func unitTarget(params map[string]any) (string, error) {
|
|
s, _ := params["service"].(string)
|
|
return Unit(s)
|
|
}
|
|
|
|
// Capabilities returns the host capability set.
|
|
func Capabilities(o Options) []capabilities.Capability {
|
|
if o.ProcRoot == "" {
|
|
o.ProcRoot = "/proc"
|
|
}
|
|
run := o.Runner
|
|
svc := map[string]capabilities.Param{"service": {Type: "string", Required: true, Description: "systemd unit, e.g. atlas-worker or atlas-worker.service", MaxLen: 128}}
|
|
return []capabilities.Capability{
|
|
{Name: "host.disk.usage", Description: "Report size, used and free space of the filesystem holding a path.", Risk: capabilities.RiskLow, TargetKind: "path", Access: capabilities.AccessRead, Idempotent: true, Timeout: 5 * time.Second,
|
|
SideEffects: "none", DefaultEffect: capabilities.Allow,
|
|
Params: map[string]capabilities.Param{"path": {Type: "path", Description: "absolute path (default /)", MaxLen: 512}},
|
|
TargetFrom: func(p map[string]any) (string, error) { return diskPath(p) },
|
|
Executor: func(_ context.Context, inv capabilities.Invocation) (capabilities.Result, error) {
|
|
path, err := diskPath(inv.Params)
|
|
if err != nil {
|
|
return capabilities.Result{}, err
|
|
}
|
|
total, free, err := statfs(path)
|
|
if err != nil {
|
|
return capabilities.Result{}, err
|
|
}
|
|
used := total - free
|
|
pct := 0.0
|
|
if total > 0 {
|
|
pct = float64(used) / float64(total) * 100
|
|
}
|
|
return capabilities.Result{Summary: fmt.Sprintf("%s: %.1f%% used (%d of %d bytes free)", path, pct, free, total),
|
|
Data: map[string]any{"path": path, "total_bytes": total, "free_bytes": free, "used_bytes": used, "used_percent": round1(pct)}}, nil
|
|
}},
|
|
{Name: "host.memory.status", Description: "Report total, available and swap memory from the kernel.", Risk: capabilities.RiskLow, TargetKind: "none", Access: capabilities.AccessRead, Idempotent: true, Timeout: 5 * time.Second,
|
|
SideEffects: "none", DefaultEffect: capabilities.Allow,
|
|
Executor: func(_ context.Context, _ capabilities.Invocation) (capabilities.Result, error) {
|
|
m, err := meminfo(filepath.Join(o.ProcRoot, "meminfo"))
|
|
if err != nil {
|
|
return capabilities.Result{}, err
|
|
}
|
|
avail := m["MemAvailable"]
|
|
pct := 0.0
|
|
if m["MemTotal"] > 0 {
|
|
pct = float64(m["MemTotal"]-avail) / float64(m["MemTotal"]) * 100
|
|
}
|
|
return capabilities.Result{Summary: fmt.Sprintf("memory %.1f%% in use, %d KiB available", pct, avail),
|
|
Data: map[string]any{"total_kib": m["MemTotal"], "available_kib": avail, "swap_total_kib": m["SwapTotal"], "swap_free_kib": m["SwapFree"], "used_percent": round1(pct)}}, nil
|
|
}},
|
|
{Name: "host.process.list", Description: "List processes by name, state and resident memory. Command lines are never read, because they can hold secrets.", Risk: capabilities.RiskLow, TargetKind: "none", Access: capabilities.AccessRead, Idempotent: true, Timeout: 10 * time.Second,
|
|
SideEffects: "none", DefaultEffect: capabilities.Ask,
|
|
Params: map[string]capabilities.Param{"match": {Type: "string", Description: "only names containing this text", MaxLen: 64}, "limit": {Type: "int", Description: "maximum rows (default 50, at most 200)"}},
|
|
Executor: func(_ context.Context, inv capabilities.Invocation) (capabilities.Result, error) {
|
|
match, _ := inv.Params["match"].(string)
|
|
limit, _ := inv.Params["limit"].(int)
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
procs, err := processes(o.ProcRoot, match, limit)
|
|
if err != nil {
|
|
return capabilities.Result{}, err
|
|
}
|
|
return capabilities.Result{Summary: fmt.Sprintf("%d processes", len(procs)), Data: map[string]any{"processes": procs}}, nil
|
|
}},
|
|
{Name: "service.status", Description: "Report a systemd unit's load, active and sub state and main PID.", Risk: capabilities.RiskLow, TargetKind: "service", Access: capabilities.AccessRead, Idempotent: true, Timeout: 10 * time.Second,
|
|
SideEffects: "none", DefaultEffect: capabilities.Ask, Params: svc, TargetFrom: unitTarget,
|
|
Executor: func(ctx context.Context, inv capabilities.Invocation) (capabilities.Result, error) {
|
|
unit, err := unitTarget(inv.Params)
|
|
if err != nil || run == nil {
|
|
return capabilities.Result{}, errors.Join(err, errNoRunner(run))
|
|
}
|
|
out, err := run.Run(ctx, "systemctl", "show", "--no-pager", "--property=LoadState,ActiveState,SubState,MainPID,ActiveEnterTimestamp,NRestarts", "--", unit)
|
|
if err != nil {
|
|
return capabilities.Result{}, err
|
|
}
|
|
props := parseProps(out)
|
|
healthy := props["ActiveState"] == "active"
|
|
return capabilities.Result{Summary: fmt.Sprintf("%s is %s (%s)", unit, props["ActiveState"], props["SubState"]), Data: map[string]any{"unit": unit, "properties": props, "active": healthy}}, nil
|
|
}},
|
|
{Name: "service.restart", Description: "Restart a systemd unit.", Risk: capabilities.RiskHigh, TargetKind: "service", Access: capabilities.AccessWrite, Idempotent: false, Timeout: 90 * time.Second,
|
|
SideEffects: "stops and starts the unit: in-flight work and connections are dropped", DefaultEffect: capabilities.Ask, Params: svc, TargetFrom: unitTarget,
|
|
Estimate: capabilities.Cost{WallSeconds: 10},
|
|
Executor: func(ctx context.Context, inv capabilities.Invocation) (capabilities.Result, error) {
|
|
unit, err := unitTarget(inv.Params)
|
|
if err != nil || run == nil {
|
|
return capabilities.Result{}, errors.Join(err, errNoRunner(run))
|
|
}
|
|
if _, err := run.Run(ctx, "systemctl", "restart", "--no-block", "--", unit); err != nil {
|
|
return capabilities.Result{}, err
|
|
}
|
|
// The command returning is not the service being healthy; a
|
|
// plan verifies with service.status afterwards.
|
|
return capabilities.Result{Summary: "restart of " + unit + " requested (health not yet verified)", Data: map[string]any{"unit": unit, "verified": false}}, nil
|
|
}},
|
|
{Name: "logs.query", Description: "Read recent journal lines for a unit. E-mail addresses, IP addresses and credential-like values are masked unless redact_pii is false.", Risk: capabilities.RiskMedium, TargetKind: "service", Access: capabilities.AccessRead, Idempotent: true, Timeout: 30 * time.Second,
|
|
SideEffects: "none; the output may contain personal data", DefaultEffect: capabilities.Ask, TargetFrom: unitTarget,
|
|
Params: map[string]capabilities.Param{"service": svc["service"], "since_minutes": {Type: "int", Description: "look back this many minutes (default 60, at most 1440)"}, "lines": {Type: "int", Description: "maximum lines (default 200, at most 1000)"},
|
|
"grep": {Type: "string", Description: "only lines containing this text", MaxLen: 200}, "redact_pii": {Type: "bool", Description: "mask personal data (default true)"}},
|
|
Estimate: capabilities.Cost{WallSeconds: 2},
|
|
Executor: func(ctx context.Context, inv capabilities.Invocation) (capabilities.Result, error) {
|
|
unit, err := unitTarget(inv.Params)
|
|
if err != nil || run == nil {
|
|
return capabilities.Result{}, errors.Join(err, errNoRunner(run))
|
|
}
|
|
since, _ := inv.Params["since_minutes"].(int)
|
|
if since <= 0 || since > 1440 {
|
|
since = 60
|
|
}
|
|
lines, _ := inv.Params["lines"].(int)
|
|
if lines <= 0 || lines > 1000 {
|
|
lines = 200
|
|
}
|
|
out, err := run.Run(ctx, "journalctl", "--no-pager", "-o", "short-iso", "-u", unit, "--since", fmt.Sprintf("-%dmin", since), "-n", strconv.Itoa(lines))
|
|
if err != nil {
|
|
return capabilities.Result{}, err
|
|
}
|
|
text := string(out)
|
|
if g, _ := inv.Params["grep"].(string); g != "" {
|
|
var kept []string
|
|
for _, l := range strings.Split(text, "\n") {
|
|
if strings.Contains(l, g) {
|
|
kept = append(kept, l)
|
|
}
|
|
}
|
|
text = strings.Join(kept, "\n")
|
|
}
|
|
redact := true
|
|
if v, ok := inv.Params["redact_pii"].(bool); ok {
|
|
redact = v
|
|
}
|
|
masked := 0
|
|
if redact {
|
|
text, masked = Scrub(text)
|
|
}
|
|
n := 0
|
|
if strings.TrimSpace(text) != "" {
|
|
n = strings.Count(strings.TrimRight(text, "\n"), "\n") + 1
|
|
}
|
|
return capabilities.Result{Summary: fmt.Sprintf("%d journal lines for %s over %d min (%d values masked)", n, unit, since, masked), Output: text,
|
|
Data: map[string]any{"unit": unit, "lines": n, "since_minutes": since, "masked": masked, "redacted": redact}}, nil
|
|
}},
|
|
}
|
|
}
|
|
|
|
func errNoRunner(r Runner) error {
|
|
if r == nil {
|
|
return errors.New("host: no command runner configured")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func diskPath(p map[string]any) (string, error) {
|
|
path, _ := p["path"].(string)
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
return "", errors.New("path must be absolute")
|
|
}
|
|
return filepath.Clean(path), nil
|
|
}
|
|
|
|
func meminfo(file string) (map[string]int64, error) {
|
|
f, err := os.Open(file) // #nosec G304 -- fixed kernel file under the configured proc root
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
out := map[string]int64{}
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
k, v, ok := strings.Cut(sc.Text(), ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
fields := strings.Fields(v)
|
|
if len(fields) == 0 {
|
|
continue
|
|
}
|
|
if n, err := strconv.ParseInt(fields[0], 10, 64); err == nil {
|
|
out[k] = n
|
|
}
|
|
}
|
|
if _, ok := out["MemTotal"]; !ok {
|
|
return nil, errors.New("meminfo: MemTotal missing")
|
|
}
|
|
return out, sc.Err()
|
|
}
|
|
|
|
// Process is one row of host.process.list.
|
|
type Process struct {
|
|
PID int `json:"pid"`
|
|
Name string `json:"name"`
|
|
State string `json:"state"`
|
|
RSSKiB int64 `json:"rss_kib"`
|
|
}
|
|
|
|
func processes(root, match string, limit int) ([]Process, error) {
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []Process
|
|
for _, e := range entries {
|
|
pid, err := strconv.Atoi(e.Name())
|
|
if err != nil || !e.IsDir() {
|
|
continue
|
|
}
|
|
stat, err := os.ReadFile(filepath.Join(root, e.Name(), "stat")) // #nosec G304 -- numeric pid directory under the proc root
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// pid (comm) state ... ; comm may contain spaces and parentheses.
|
|
s := string(stat)
|
|
open, close := strings.IndexByte(s, '('), strings.LastIndexByte(s, ')')
|
|
if open < 0 || close < open {
|
|
continue
|
|
}
|
|
name := s[open+1 : close]
|
|
rest := strings.Fields(s[close+1:])
|
|
if match != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(match)) {
|
|
continue
|
|
}
|
|
p := Process{PID: pid, Name: name}
|
|
if len(rest) > 0 {
|
|
p.State = rest[0]
|
|
}
|
|
if len(rest) > 21 {
|
|
if pages, err := strconv.ParseInt(rest[21], 10, 64); err == nil {
|
|
p.RSSKiB = pages * int64(os.Getpagesize()) / 1024
|
|
}
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].RSSKiB > out[j].RSSKiB })
|
|
if len(out) > limit {
|
|
out = out[:limit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func parseProps(out []byte) map[string]string {
|
|
props := map[string]string{}
|
|
for _, l := range strings.Split(string(out), "\n") {
|
|
if k, v, ok := strings.Cut(strings.TrimSpace(l), "="); ok {
|
|
props[k] = v
|
|
}
|
|
}
|
|
return props
|
|
}
|
|
|
|
var (
|
|
emailRe = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
|
|
ipv4Re = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
|
|
ipv6Re = regexp.MustCompile(`\b(?:[0-9a-fA-F]{1,4}:){3,7}[0-9a-fA-F]{1,4}\b`)
|
|
secretRe = regexp.MustCompile(`(?i)\b(password|passwd|secret|token|api[_-]?key|authorization)(\s*[=:]\s*|\s+)(bearer\s+)?\S+`)
|
|
)
|
|
|
|
// Scrub masks e-mail addresses, IP addresses and credential-like values and
|
|
// returns the text with the number of values masked. It is a mitigation, not
|
|
// a guarantee: free-text personal data (names, addresses) passes through.
|
|
func Scrub(text string) (string, int) {
|
|
n := 0
|
|
count := func(re *regexp.Regexp, repl func(string) string) {
|
|
text = re.ReplaceAllStringFunc(text, func(m string) string { n++; return repl(m) })
|
|
}
|
|
count(secretRe, func(m string) string {
|
|
parts := secretRe.FindStringSubmatch(m)
|
|
return parts[1] + parts[2] + "[masked]"
|
|
})
|
|
count(emailRe, func(string) string { return "[email]" })
|
|
count(ipv6Re, func(string) string { return "[ip]" })
|
|
count(ipv4Re, func(string) string { return "[ip]" })
|
|
return text, n
|
|
}
|
|
|
|
func round1(f float64) float64 { return float64(int64(f*10+0.5)) / 10 }
|