diff --git a/README.md b/README.md index f2f784b7..bcef3f5b 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Every access path is default-deny: | Resource | Default | Opt-in | |----------------------|-------------------------------------|----------------------------------------------| | Command execution | All commands blocked (exit code 127)| `AllowedCommands` with namespaced command list (e.g. `rshell:cat`) | +| System services | All services and actions blocked | `AllowedSystemServices` with exact service/action grants | | External commands | Blocked (exit code 127) | Provide an `ExecHandler` | | Filesystem access | Blocked | Configure `AllowedPaths` with `PATH[:ro|:rw]` root specs | | Environment variables| Empty (no host env inherited) | Pass variables via the `Env` option | @@ -68,6 +69,23 @@ Every access path is default-deny: **AllowedCommands** restricts which commands (builtins or external) the interpreter may execute. Commands must be specified with the `rshell:` namespace prefix (e.g. `rshell:cat`, `rshell:echo`). If not set, no commands are allowed. +**AllowedSystemServices** configures exact service/action grants for future system-service builtins. Service names are matched exactly without adding suffixes, resolving aliases, changing case, or otherwise normalizing them: `mysql` and `mysql.service` are different grants. The supported actions are `read`, `reload`, and `restart`. Empty names, whitespace, path-like names, and glob patterns are rejected. The policy defaults to denying every service, remains enforced when all commands are allowed, and requires remediation mode before any grant can be authorized. + +```go +interp.AllowedSystemServices([]interp.SystemServiceControlGrant{ + { + Service: "mysql.service", + Actions: []interp.SystemServiceAction{ + interp.SystemServiceRestart, + interp.SystemServiceReload, + interp.SystemServiceRead, + }, + }, +}) +``` + +The development CLI accepts the equivalent syntax through `--allowed-services mysql.service:restart+reload+read,nginx.service:read`. The policy and authorization capability are implemented, but `systemctl` and `journalctl` builtins are not yet available. + **AllowedPaths** restricts all file operations to specified directories using Go's `os.Root` API for reads and openat-based write handling for writes. - **Sandbox mechanism:** Reads go through `os.Root`; writes are checked against the most-specific path mode and, on Unix, opened with a no-symlink `openat` walk. Files outside the allowlist cannot be opened, created, truncated, or appended to. @@ -83,7 +101,7 @@ Every access path is default-deny: **ProcPath** (Linux-only) overrides the proc filesystem root used by the `ps` builtin (default `/proc`). This is a privileged option set at runner construction time by trusted caller code — scripts cannot influence it. Access to the proc path is intentionally not subject to `AllowedPaths` restrictions. To avoid leaking secrets passed as CLI arguments, `ps` does not read `/proc//cmdline`; the `CMD` column reports only the process comm/executable name. -**RemediationMode** opts the runner into host-remediation mode, enabling file-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) within `:rw` entries in the configured `AllowedPaths` and enabling remediation-only builtins such as `truncate` and `logrotate`. Targets outside the allowlist or inside read-only roots are rejected with `permission denied` (exit 1); symlinked write targets are rejected with `symlinks are not supported as write targets`; `/dev/null` is always accepted. `<>` (read-write open) remains blocked in all modes. CLI flag: `--mode remediation` (default: `--mode read-only`). The `logrotate` builtin is an rshell-safe log truncation helper, not a full `logrotate(8)` replacement. +**RemediationMode** opts the runner into host-remediation mode, enabling file-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) within `:rw` entries in the configured `AllowedPaths` and enabling remediation-only builtins such as `truncate` and `logrotate`. Remediation-only commands are rejected by builtin dispatch before argument parsing when this mode is disabled. Targets outside the allowlist or inside read-only roots are rejected with `permission denied` (exit 1); symlinked write targets are rejected with `symlinks are not supported as write targets`; `/dev/null` is always accepted. `<>` (read-write open) remains blocked in all modes. CLI flag: `--mode remediation` (default: `--mode read-only`). The `logrotate` builtin is an rshell-safe log truncation helper, not a full `logrotate(8)` replacement. ## Shell Features diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index c8cb7b2e..d8617490 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -121,10 +121,11 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c ## Execution - ✅ AllowedCommands — restricts which commands (builtins or external) may be executed; commands require the `rshell:` namespace prefix (e.g. `rshell:cat`); if not set, no commands are allowed +- ✅ AllowedSystemServices policy — configures exact, case-sensitive `read`, `reload`, and `restart` grants for system service names through `interp.AllowedSystemServices` or CLI `--allowed-services SERVICE:ACTION[+ACTION...]`; names are not normalized (`mysql` does not match `mysql.service`), glob and path-like names are rejected, all services are denied by default, and allowing all commands does not bypass this policy; the authorization capability is implemented for future consumers, but `systemctl` and `journalctl` builtins are not yet available - ✅ AllowedPaths filesystem sandboxing — restricts all file access (read and write) to specified directories. Entries may end with `:ro` or `:rw` to indicate read-only and read-write permissions, respectively; entries without a suffix default to read-only. In remediation mode, write operations are accepted only inside the most-specific matching `:rw` root. Cross-root symlink fallback is read-only to avoid TOCTOU on writes; on Unix, symlink components in write targets are rejected with `symlinks are not supported as write targets` via a no-follow `openat` walk - ✅ Whole-run execution timeout — callers can bound a `Run()` call via `context.Context`, `interp.MaxExecutionTime`, or the CLI `--timeout` flag; the deadline applies to the entire script, not each individual command - ✅ ProcPath — overrides the proc filesystem path used by `ps` (default `/proc`; Linux-only; useful for testing/container environments); `ps` does not read `/proc//cmdline` -- ✅ RemediationMode — opt-in mode (`interp.WithMode(interp.ModeRemediation)` / `--mode remediation`) that enables file-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) within `:rw` `AllowedPaths` and remediation-only builtins such as `truncate` and `logrotate`; targets outside the allowlist or inside read-only roots fail with `permission denied` (exit 1); symlinked write targets fail with `symlinks are not supported as write targets`; `/dev/null` always accepted; `<>` remains blocked +- ✅ RemediationMode — opt-in mode (`interp.WithMode(interp.ModeRemediation)` / `--mode remediation`) that enables file-target output redirections (`>`, `>>`, `2>`, `&>`, `&>>`) within `:rw` `AllowedPaths` and remediation-only builtins such as `truncate` and `logrotate`; builtin dispatch rejects remediation-only commands before argument parsing when the mode is disabled; targets outside the allowlist or inside read-only roots fail with `permission denied` (exit 1); symlinked write targets fail with `symlinks are not supported as write targets`; `/dev/null` always accepted; `<>` remains blocked - ❌ External commands — blocked by default; requires an ExecHandler to be configured and the binary to be within AllowedPaths - ❌ Background execution: `cmd &` - ❌ Coprocesses: `coproc` diff --git a/builtins/builtins.go b/builtins/builtins.go index c7d1949f..08acbfd5 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -57,6 +57,16 @@ type AllowedPath struct { Access AllowedPathAccess } +// SystemServiceAction identifies an operation that a builtin may perform on +// an explicitly configured system service. +type SystemServiceAction string + +const ( + SystemServiceRead SystemServiceAction = "read" + SystemServiceReload SystemServiceAction = "reload" + SystemServiceRestart SystemServiceAction = "restart" +) + // Command pairs a builtin name with its flag-declaring factory. MakeFlags // registers any flags on the provided FlagSet and returns the bound handler. // Commands that accept no flags may ignore fs via NoFlags. @@ -72,8 +82,8 @@ type Command struct { NormalizeArgs func(args []string) []string // RemediationOnly marks a builtin as only available in remediation mode. - // The help builtin uses this to move the command to the disabled list - // when the shell is in read-only mode. + // Register rejects the command before argument parsing in read-only mode; + // the help builtin also uses this to move the command to the disabled list. RemediationOnly bool } @@ -95,6 +105,7 @@ func (c Command) Register() { name := c.Name factory := c.MakeFlags normalize := c.NormalizeArgs + remediationOnly := c.RemediationOnly if _, exists := featureByName[name]; exists { panic("builtin name conflicts with rshell feature: " + name) } @@ -108,6 +119,11 @@ func (c Command) Register() { metaRegistry[name] = CommandMeta{Name: name, Description: c.Description, Help: c.Help, HasFlags: hasFlags, RemediationOnly: c.RemediationOnly} addToRegistry(name, func(ctx context.Context, callCtx *CallContext, args []string) Result { + if remediationOnly && !callCtx.RemediationMode { + callCtx.Errf("%s: command requires remediation mode\n", name) + return Result{Code: 1} + } + fs := pflag.NewFlagSet(name, pflag.ContinueOnError) fs.SetOutput(io.Discard) // handler formats errors itself handler := factory(fs) @@ -245,6 +261,12 @@ type CallContext struct { // commands. CommandAllowed func(name string) bool + // AuthorizeSystemServices reports whether all named services may be used + // for action under the current shell policy. Implementations must authorize + // the complete list before a builtin performs any operation so multi-service + // requests cannot partially execute. Service names are matched exactly. + AuthorizeSystemServices func(action SystemServiceAction, services ...string) error + // AllowedPathsList returns the resolved absolute paths and configured // access modes of the AllowedPaths sandbox roots. An empty/nil slice means // no allowed paths are configured, which blocks all filesystem access. diff --git a/builtins/remediation_test.go b/builtins/remediation_test.go new file mode 100644 index 00000000..60431f35 --- /dev/null +++ b/builtins/remediation_test.go @@ -0,0 +1,54 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package builtins + +import ( + "bytes" + "context" + "testing" +) + +func TestCommandRegisterEnforcesRemediationOnly(t *testing.T) { + const name = "test-remediation-only" + defer delete(registry, name) + defer delete(metaRegistry, name) + + called := false + Command{ + Name: name, + Description: "test remediation enforcement", + RemediationOnly: true, + MakeFlags: NoFlags(func(context.Context, *CallContext, []string) Result { + called = true + return Result{} + }), + }.Register() + + handler, ok := Lookup(name) + if !ok { + t.Fatal("registered command was not found") + } + + var stderr bytes.Buffer + result := handler(context.Background(), &CallContext{Stderr: &stderr}, nil) + if result.Code != 1 { + t.Fatalf("read-only result code = %d, want 1", result.Code) + } + if called { + t.Fatal("remediation-only handler ran in read-only mode") + } + if got, want := stderr.String(), name+": command requires remediation mode\n"; got != want { + t.Fatalf("stderr = %q, want %q", got, want) + } + + result = handler(context.Background(), &CallContext{Stderr: &stderr, RemediationMode: true}, nil) + if result.Code != 0 { + t.Fatalf("remediation result code = %d, want 0", result.Code) + } + if !called { + t.Fatal("remediation-only handler did not run in remediation mode") + } +} diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index 32cfd40b..68d227c8 100644 --- a/cmd/rshell/main.go +++ b/cmd/rshell/main.go @@ -39,6 +39,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. command string allowedPaths string allowedCommands string + allowedServices string allowAllCmds bool timeout time.Duration procPath string @@ -84,6 +85,11 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. cmds = strings.Split(allowedCommands, ",") } + serviceGrants, err := parseAllowedServices(allowedServices) + if err != nil { + return err + } + parsedMode := interp.Mode(mode) if parsedMode != interp.ModeReadOnly && parsedMode != interp.ModeRemediation { return fmt.Errorf("--mode must be one of: read-only, remediation") @@ -92,6 +98,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. execOpts := executeOpts{ allowedPaths: paths, allowedCommands: cmds, + allowedServices: serviceGrants, allowAllCommands: allowAllCmds, procPath: procPath, mode: parsedMode, @@ -144,6 +151,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. cmd.Flags().MarkHidden("command") //nolint:errcheck // flag is guaranteed to exist cmd.Flags().StringVarP(&allowedPaths, "allowed-paths", "p", "", "comma-separated list of PATH[:ro|:rw] directories the shell is allowed to access; entries without a suffix are read-only") cmd.Flags().StringVar(&allowedCommands, "allowed-commands", "", "comma-separated list of namespaced commands (e.g. rshell:cat,rshell:find)") + cmd.Flags().StringVar(&allowedServices, "allowed-services", "", "comma-separated exact service grants in SERVICE:ACTION[+ACTION...] form; actions: read, reload, restart") cmd.Flags().BoolVar(&allowAllCmds, "allow-all-commands", false, "allow execution of all commands (builtins and external)") cmd.Flags().DurationVar(&timeout, "timeout", 0, "maximum execution time for the entire shell run (e.g. 100ms, 5s, 1m)") cmd.Flags().StringVar(&procPath, "proc-path", "", "path to the proc filesystem used by ps (default \"/proc\")") @@ -215,6 +223,7 @@ func rejectLongCommand(rawArgs []string) error { type executeOpts struct { allowedPaths []string allowedCommands []string + allowedServices []interp.SystemServiceControlGrant allowAllCommands bool procPath string mode interp.Mode @@ -241,6 +250,9 @@ func execute(ctx context.Context, script, name string, opts executeOpts, stdin i } else if len(opts.allowedCommands) > 0 { runOpts = append(runOpts, interp.AllowedCommands(opts.allowedCommands)) } + if len(opts.allowedServices) > 0 { + runOpts = append(runOpts, interp.AllowedSystemServices(opts.allowedServices)) + } if opts.procPath != "" { runOpts = append(runOpts, interp.ProcPath(opts.procPath)) } @@ -256,3 +268,29 @@ func execute(ctx context.Context, script, name string, opts executeOpts, stdin i return runner.Run(ctx, prog) } + +func parseAllowedServices(value string) ([]interp.SystemServiceControlGrant, error) { + if value == "" { + return nil, nil + } + + entries := strings.Split(value, ",") + grants := make([]interp.SystemServiceControlGrant, 0, len(entries)) + for _, entry := range entries { + separator := strings.LastIndexByte(entry, ':') + if separator <= 0 || separator == len(entry)-1 { + return nil, fmt.Errorf("--allowed-services: invalid grant %q (expected SERVICE:ACTION[+ACTION...])", entry) + } + + actionNames := strings.Split(entry[separator+1:], "+") + actions := make([]interp.SystemServiceAction, len(actionNames)) + for i, action := range actionNames { + actions[i] = interp.SystemServiceAction(action) + } + grants = append(grants, interp.SystemServiceControlGrant{ + Service: entry[:separator], + Actions: actions, + }) + } + return grants, nil +} diff --git a/cmd/rshell/main_test.go b/cmd/rshell/main_test.go index df8315dd..713fb24f 100644 --- a/cmd/rshell/main_test.go +++ b/cmd/rshell/main_test.go @@ -179,6 +179,8 @@ func TestHelp(t *testing.T) { assert.Contains(t, stdout, "PATH[:ro|:rw]") assert.Contains(t, stdout, "entries without a suffix are read-only") assert.Contains(t, stdout, "--allowed-commands") + assert.Contains(t, stdout, "--allowed-services") + assert.Contains(t, stdout, "SERVICE:ACTION[+ACTION...]") assert.Contains(t, stdout, "--allow-all-commands") assert.Contains(t, stdout, "file-target output redirections within :rw AllowedPaths roots") assert.Contains(t, stdout, "--timeout") @@ -284,6 +286,56 @@ func TestAllowedCommandsUnknownNamespace(t *testing.T) { assert.Contains(t, stderr, "unknown namespace") } +func TestAllowedServicesFlag(t *testing.T) { + code, stdout, stderr := runCLI(t, + "--allow-all-commands", + "--allowed-services", "mysql.service:restart+reload+read,nginx.service:read", + "--mode", "remediation", + "-c", `echo hello`, + ) + assert.Equal(t, 0, code) + assert.Equal(t, "hello\n", stdout) + assert.Empty(t, stderr) +} + +func TestAllowedServicesFlagRejectsInvalidGrant(t *testing.T) { + code, _, stderr := runCLI(t, + "--allow-all-commands", + "--allowed-services", "mysql.service", + "-c", `echo hello`, + ) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "invalid grant") +} + +func TestAllowedServicesFlagRejectsUnknownAction(t *testing.T) { + code, _, stderr := runCLI(t, + "--allow-all-commands", + "--allowed-services", "mysql.service:stop", + "-c", `echo hello`, + ) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, `unsupported action "stop"`) +} + +func TestAllowedServicesFlagRejectsGlob(t *testing.T) { + code, _, stderr := runCLI(t, + "--allow-all-commands", + "--allowed-services", "mysql*.service:read", + "-c", `echo hello`, + ) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "glob pattern") +} + +func TestParseAllowedServicesUsesLastColon(t *testing.T) { + grants, err := parseAllowedServices("tenant:mysql.service:read+reload") + require.NoError(t, err) + require.Len(t, grants, 1) + assert.Equal(t, "tenant:mysql.service", grants[0].Service) + assert.Equal(t, []interp.SystemServiceAction{interp.SystemServiceRead, interp.SystemServiceReload}, grants[0].Actions) +} + func TestAllowAllCommandsFlag(t *testing.T) { code, stdout, _ := runCLI(t, "--allow-all-commands", "-c", `echo hello`) assert.Equal(t, 0, code) diff --git a/interp/api.go b/interp/api.go index 724b7802..7ecf07e1 100644 --- a/interp/api.go +++ b/interp/api.go @@ -82,6 +82,11 @@ type runnerConfig struct { // command. Intended for testing convenience. allowAllCommands bool + // allowedSystemServices maps exact service names to their permitted + // actions. It is independent of allowAllCommands and defaults to denying + // every service. + allowedSystemServices systemServiceGrants + // maxExecutionTime bounds the duration of each Run call. Zero disables // the limit. When non-zero, Run derives a child context with this timeout. maxExecutionTime time.Duration diff --git a/interp/builtin_truncate_pentest_test.go b/interp/builtin_truncate_pentest_test.go index e601cdda..4f277836 100644 --- a/interp/builtin_truncate_pentest_test.go +++ b/interp/builtin_truncate_pentest_test.go @@ -45,7 +45,8 @@ func TestTruncatePentestSandboxReadOnly(t *testing.T) { _, stderr, code := runScript(t, "truncate -s 0 f.txt", dir, interp.AllowedPaths([]string{dir}), // Deliberately omit interp.WithMode(interp.ModeRemediation) — - // this means callCtx.Truncate is nil, which is the mode-guard path. + // central remediation-only dispatch must reject the command before its + // handler can reach the nil Truncate capability. ) assert.Equal(t, 1, code) assert.Contains(t, stderr, "truncate") diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 07c5e206..d49d52d4 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -662,6 +662,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { CommandAllowed: func(n string) bool { return r.allowAllCommands || r.allowedCommands[n] }, + AuthorizeSystemServices: r.authorizeSystemServices, AllowedPathsList: func() []builtins.AllowedPath { return allowedPathsList(r.sandbox) }, @@ -696,7 +697,8 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { // available", which is the closest analogue to bash's // "exec read fails because read is a builtin, not an // executable on PATH" behaviour. - Proc: r.proc, + Proc: r.proc, + RemediationMode: r.remediationMode, } if r.remediationMode && r.sandbox != nil { child.Truncate = func(ctx context.Context, path string, size int64, create bool) error { @@ -789,6 +791,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { CommandAllowed: func(cmdName string) bool { return r.allowAllCommands || r.allowedCommands[cmdName] }, + AuthorizeSystemServices: r.authorizeSystemServices, AllowedPathsList: func() []builtins.AllowedPath { return allowedPathsList(r.sandbox) }, diff --git a/interp/system_services.go b/interp/system_services.go new file mode 100644 index 00000000..80c451bd --- /dev/null +++ b/interp/system_services.go @@ -0,0 +1,123 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package interp + +import ( + "fmt" + "strings" + + "github.com/DataDog/rshell/builtins" +) + +// SystemServiceAction identifies an operation that may be granted for a +// system service. +type SystemServiceAction = builtins.SystemServiceAction + +const ( + SystemServiceRead = builtins.SystemServiceRead + SystemServiceReload = builtins.SystemServiceReload + SystemServiceRestart = builtins.SystemServiceRestart +) + +// SystemServiceControlGrant grants Actions for one exact Service spelling. +// Service names are never normalized, expanded, or resolved as aliases. +type SystemServiceControlGrant struct { + Service string + Actions []SystemServiceAction +} + +type systemServiceGrants map[string]map[SystemServiceAction]struct{} + +const systemServiceWhitespace = " \t\n\v\f\r\u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000" + +// AllowedSystemServices configures the system services and actions that +// system-service builtins may use. A grant matches its Service exactly: for +// example, "mysql" and "mysql.service" are different service names. +// +// Empty service names, whitespace, control characters, path separators, and +// glob patterns are rejected. Supported actions are read, reload, and restart. +// Duplicate services and actions are accepted and combined idempotently. +// +// When not set (default), or when passed an empty slice, every system service +// is denied. This policy is not bypassed by allowing all commands. +func AllowedSystemServices(grants []SystemServiceControlGrant) RunnerOption { + return func(r *Runner) error { + allowed := make(systemServiceGrants, len(grants)) + for i, grant := range grants { + if err := validateSystemServiceName(grant.Service); err != nil { + return fmt.Errorf("AllowedSystemServices: grant %d: %w", i, err) + } + if len(grant.Actions) == 0 { + return fmt.Errorf("AllowedSystemServices: grant %d for %q has no actions", i, grant.Service) + } + + actions := allowed[grant.Service] + if actions == nil { + actions = make(map[SystemServiceAction]struct{}, len(grant.Actions)) + allowed[grant.Service] = actions + } + for _, action := range grant.Actions { + if !validSystemServiceAction(action) { + return fmt.Errorf("AllowedSystemServices: grant %d for %q has unsupported action %q", i, grant.Service, action) + } + actions[action] = struct{}{} + } + } + r.allowedSystemServices = allowed + return nil + } +} + +func validSystemServiceAction(action SystemServiceAction) bool { + switch action { + case SystemServiceRead, SystemServiceReload, SystemServiceRestart: + return true + default: + return false + } +} + +func validateSystemServiceName(service string) error { + if service == "" { + return fmt.Errorf("system service name must not be empty") + } + if strings.ContainsRune(service, '/') { + return fmt.Errorf("system service name %q must not contain a path separator", service) + } + for _, r := range service { + switch r { + case '*', '?', '[', ']': + return fmt.Errorf("system service name %q must not contain a glob pattern", service) + } + if strings.ContainsRune(systemServiceWhitespace, r) || r < ' ' || (r >= 0x7f && r <= 0x9f) { + return fmt.Errorf("system service name %q must not contain whitespace or control characters", service) + } + } + return nil +} + +func (r *Runner) authorizeSystemServices(action SystemServiceAction, services ...string) error { + if !r.remediationMode { + return fmt.Errorf("system service actions require remediation mode") + } + if !validSystemServiceAction(action) { + return fmt.Errorf("unsupported system service action %q", action) + } + if len(services) == 0 { + return fmt.Errorf("at least one system service is required") + } + + for _, service := range services { + if err := validateSystemServiceName(service); err != nil { + return err + } + actions := r.allowedSystemServices[service] + if _, ok := actions[action]; !ok { + return fmt.Errorf("system service %q is not allowed for action %q", service, action) + } + } + return nil +} diff --git a/interp/system_services_test.go b/interp/system_services_test.go new file mode 100644 index 00000000..d60a712e --- /dev/null +++ b/interp/system_services_test.go @@ -0,0 +1,176 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package interp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAllowedSystemServicesAuthorizesExactServiceAndAction(t *testing.T) { + runner, err := New( + WithMode(ModeRemediation), + AllowedSystemServices([]SystemServiceControlGrant{ + { + Service: "mysql.service", + Actions: []SystemServiceAction{ + SystemServiceRestart, + SystemServiceReload, + SystemServiceRead, + }, + }, + { + Service: "nginx.service", + Actions: []SystemServiceAction{SystemServiceRead}, + }, + }), + ) + require.NoError(t, err) + defer runner.Close() + + require.NoError(t, runner.authorizeSystemServices(SystemServiceRestart, "mysql.service")) + require.NoError(t, runner.authorizeSystemServices(SystemServiceReload, "mysql.service")) + require.NoError(t, runner.authorizeSystemServices(SystemServiceRead, "mysql.service", "nginx.service")) + + err = runner.authorizeSystemServices(SystemServiceRestart, "nginx.service") + require.Error(t, err) + assert.Contains(t, err.Error(), `system service "nginx.service" is not allowed for action "restart"`) + + for _, service := range []string{"mysql", "MYSQL.service"} { + err = runner.authorizeSystemServices(SystemServiceRead, service) + require.Error(t, err) + assert.Contains(t, err.Error(), `system service "`+service+`" is not allowed`) + } +} + +func TestAllowedSystemServicesDefaultDenyIsIndependentOfAllowedCommands(t *testing.T) { + runner, err := New(WithMode(ModeRemediation), allowAllCommandsOpt()) + require.NoError(t, err) + defer runner.Close() + + err = runner.authorizeSystemServices(SystemServiceRead, "mysql.service") + require.Error(t, err) + assert.Contains(t, err.Error(), "not allowed") +} + +func TestAllowedSystemServicesRequiresRemediationMode(t *testing.T) { + runner, err := New(AllowedSystemServices([]SystemServiceControlGrant{ + { + Service: "mysql.service", + Actions: []SystemServiceAction{SystemServiceRead}, + }, + })) + require.NoError(t, err) + defer runner.Close() + + err = runner.authorizeSystemServices(SystemServiceRead, "mysql.service") + require.Error(t, err) + assert.Contains(t, err.Error(), "require remediation mode") +} + +func TestAllowedSystemServicesCopiesAndCombinesGrants(t *testing.T) { + actions := []SystemServiceAction{SystemServiceRead} + grants := []SystemServiceControlGrant{ + {Service: "mysql", Actions: actions}, + {Service: "mysql", Actions: []SystemServiceAction{SystemServiceRestart}}, + } + runner, err := New(WithMode(ModeRemediation), AllowedSystemServices(grants)) + require.NoError(t, err) + defer runner.Close() + + grants[0].Service = "changed" + actions[0] = SystemServiceReload + + require.NoError(t, runner.authorizeSystemServices(SystemServiceRead, "mysql")) + require.NoError(t, runner.authorizeSystemServices(SystemServiceRestart, "mysql")) +} + +func TestAllowedSystemServicesRejectsInvalidConfiguration(t *testing.T) { + tests := []struct { + name string + grant SystemServiceControlGrant + needle string + }{ + { + name: "empty service", + grant: SystemServiceControlGrant{Service: "", Actions: []SystemServiceAction{SystemServiceRead}}, + needle: "must not be empty", + }, + { + name: "whitespace", + grant: SystemServiceControlGrant{Service: "mysql service", Actions: []SystemServiceAction{SystemServiceRead}}, + needle: "whitespace or control characters", + }, + { + name: "unicode whitespace", + grant: SystemServiceControlGrant{Service: "mysql\u00a0service", Actions: []SystemServiceAction{SystemServiceRead}}, + needle: "whitespace or control characters", + }, + { + name: "path", + grant: SystemServiceControlGrant{Service: "/etc/systemd/system/mysql.service", Actions: []SystemServiceAction{SystemServiceRead}}, + needle: "path separator", + }, + { + name: "glob", + grant: SystemServiceControlGrant{Service: "mysql*.service", Actions: []SystemServiceAction{SystemServiceRead}}, + needle: "glob pattern", + }, + { + name: "no actions", + grant: SystemServiceControlGrant{Service: "mysql.service"}, + needle: "has no actions", + }, + { + name: "unknown action", + grant: SystemServiceControlGrant{Service: "mysql.service", Actions: []SystemServiceAction{"stop"}}, + needle: `unsupported action "stop"`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runner, err := New(AllowedSystemServices([]SystemServiceControlGrant{test.grant})) + if runner != nil { + runner.Close() + } + require.Error(t, err) + assert.Contains(t, err.Error(), test.needle) + }) + } +} + +func TestAuthorizeSystemServicesRejectsInvalidRequests(t *testing.T) { + runner, err := New(WithMode(ModeRemediation), AllowedSystemServices([]SystemServiceControlGrant{ + { + Service: "mysql.service", + Actions: []SystemServiceAction{SystemServiceRead}, + }, + })) + require.NoError(t, err) + defer runner.Close() + + tests := []struct { + name string + action SystemServiceAction + services []string + needle string + }{ + {name: "unknown action", action: "stop", services: []string{"mysql.service"}, needle: "unsupported system service action"}, + {name: "no services", action: SystemServiceRead, needle: "at least one system service"}, + {name: "runtime glob", action: SystemServiceRead, services: []string{"mysql*.service"}, needle: "glob pattern"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := runner.authorizeSystemServices(test.action, test.services...) + require.Error(t, err) + assert.Contains(t, err.Error(), test.needle) + }) + } +} diff --git a/tests/scenarios/cmd/logrotate/errors/readonly_mode.yaml b/tests/scenarios/cmd/logrotate/errors/readonly_mode.yaml index 31c0ffcf..f177bcef 100644 --- a/tests/scenarios/cmd/logrotate/errors/readonly_mode.yaml +++ b/tests/scenarios/cmd/logrotate/errors/readonly_mode.yaml @@ -12,5 +12,5 @@ input: expect: stdout: |+ stderr: |+ - logrotate: filesystem capability not available (remediation mode required) + logrotate: command requires remediation mode exit_code: 1 diff --git a/tests/scenarios/cmd/truncate/errors/readonly_mode.yaml b/tests/scenarios/cmd/truncate/errors/readonly_mode.yaml index b85bf000..ca12439e 100644 --- a/tests/scenarios/cmd/truncate/errors/readonly_mode.yaml +++ b/tests/scenarios/cmd/truncate/errors/readonly_mode.yaml @@ -1,5 +1,5 @@ # Security test: truncate in read-only mode (no remediation) is rejected. -description: truncate without remediation mode fails with "capability not available". +description: truncate without remediation mode is rejected before its handler runs. setup: files: - path: f.txt @@ -11,6 +11,6 @@ input: expect: stdout: |+ stderr: |+ - truncate: filesystem capability not available (remediation mode required) + truncate: command requires remediation mode exit_code: 1 skip_assert_against_bash: true diff --git a/tests/scenarios/cmd/truncate/errors/readonly_mode_help.yaml b/tests/scenarios/cmd/truncate/errors/readonly_mode_help.yaml index d03a8e9b..b1dc61cb 100644 --- a/tests/scenarios/cmd/truncate/errors/readonly_mode_help.yaml +++ b/tests/scenarios/cmd/truncate/errors/readonly_mode_help.yaml @@ -1,4 +1,4 @@ -description: truncate --help in read-only mode fails with the same error as truncate itself. +description: truncate --help in read-only mode is rejected before help is displayed. input: allowed_paths: ["$DIR"] script: |+ @@ -6,6 +6,6 @@ input: expect: stdout: |+ stderr: |+ - truncate: filesystem capability not available (remediation mode required) + truncate: command requires remediation mode exit_code: 1 skip_assert_against_bash: true