summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Magefile.go249
-rw-r--r--docs/sudo-hardening-plan.md108
-rw-r--r--docs/sudo-rules-for-ior.txt88
3 files changed, 240 insertions, 205 deletions
diff --git a/Magefile.go b/Magefile.go
index a708fb7..25b1a97 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -5,7 +5,6 @@ package main
import (
"bufio"
- "encoding/json"
"errors"
"fmt"
"go/format"
@@ -203,13 +202,15 @@ func TestWithName() error {
fmt.Println("Running integration test", testName, "(requires root)...")
env := goEnv()
forwardEnv(env, "HOME", "GOPATH", "GOMODCACHE", "PATH", "GOTOOLCHAIN")
- return runGoTestWithProgress(env,
- "./integrationtests/...",
- "-run", "^"+testName+"$",
- "-failfast",
- "-timeout=30m",
- "-count=1",
- "-json",
+ if err := compileIntegrationTestBinary(env); err != nil {
+ return err
+ }
+ return runIntegrationTestBinary(env,
+ "-test.run", "^"+testName+"$",
+ "-test.failfast",
+ "-test.timeout=30m",
+ "-test.count=1",
+ "-test.v",
)
}
return sh.RunWithV(goEnv(), "go", "test", "./...", "-run", "^"+testName+"$", "-v", "-failfast")
@@ -519,6 +520,29 @@ func IntegrationTestSerial() error {
return runIntegrationTests(false)
}
+func compileIntegrationTestBinary(env map[string]string) error {
+ return sh.RunWithV(env, "go", "test", "-c", "./integrationtests/...", "-o", "integrationtests.test")
+}
+
+func runIntegrationTestBinary(env map[string]string, args ...string) error {
+ envList := make([]string, 0, len(env))
+ for k, v := range env {
+ envList = append(envList, k+"="+v)
+ }
+ slices.Sort(envList)
+
+ cmd := exec.Command("./integrationtests.test", args...)
+ cmd.Dir = "integrationtests"
+ cmd.Env = append(os.Environ(), envList...)
+ if os.Geteuid() == 0 {
+ return cmd.Run()
+ }
+ sudoCmd := exec.Command("sudo", append([]string{"-n", "-E", "./integrationtests.test"}, args...)...)
+ sudoCmd.Dir = "integrationtests"
+ sudoCmd.Env = cmd.Env
+ return sudoCmd.Run()
+}
+
func runIntegrationTests(parallel bool) error {
mg.SerialDeps(All)
if err := buildWorkloadBinary(); err != nil {
@@ -528,16 +552,19 @@ func runIntegrationTests(parallel bool) error {
env := goEnv()
forwardEnv(env, "HOME", "GOPATH", "GOMODCACHE", "GOTOOLCHAIN")
+ if err := compileIntegrationTestBinary(env); err != nil {
+ return err
+ }
+
timeout := "30m"
if !parallel {
timeout = "90m"
}
args := []string{
- "./integrationtests/...",
- "-failfast",
- "-timeout=" + timeout,
- "-count=1",
+ "-test.failfast",
+ "-test.timeout=" + timeout,
+ "-test.count=1",
}
if parallel {
@@ -547,13 +574,12 @@ func runIntegrationTests(parallel bool) error {
}
env[integrationParallelE] = "1"
fmt.Printf("Running integration tests in parallel (requires root, parallel=%d)...\n", parallelism)
- args = append(args, "-parallel", strconv.Itoa(parallelism))
+ args = append(args, "-test.parallel", strconv.Itoa(parallelism))
} else {
fmt.Println("Running integration tests serially (requires root)...")
}
- args = append(args, "-json")
- return runGoTestWithProgress(env, args...)
+ return runIntegrationTestBinary(env, args...)
}
func resolveIntegrationParallelism() (int, error) {
@@ -810,7 +836,7 @@ func sudoOutput(cmd string, args ...string) (string, error) {
if os.Geteuid() == 0 {
return sh.Output(cmd, args...)
}
- return sh.Output("sudo", append([]string{cmd}, args...)...)
+ return sh.Output("sudo", append([]string{"-n", cmd}, args...)...)
}
func sudoRunWithEnv(env map[string]string, cmd string, args ...string) error {
@@ -822,8 +848,8 @@ func sudoRunWithEnv(env map[string]string, cmd string, args ...string) error {
keys = append(keys, k)
}
slices.Sort(keys)
- sudoArgs := make([]string, 0, 1+len(keys)+1+len(args))
- sudoArgs = append(sudoArgs, "env")
+ sudoArgs := make([]string, 0, 2+len(keys)+1+len(args))
+ sudoArgs = append(sudoArgs, "-n", "env")
for _, k := range keys {
sudoArgs = append(sudoArgs, k+"="+env[k])
}
@@ -897,193 +923,6 @@ func sortLinesWithLocale(lines []string) (string, error) {
return string(output), nil
}
-type goTestEvent struct {
- Action string `json:"Action"`
- Package string `json:"Package"`
- Test string `json:"Test"`
- Output string `json:"Output"`
-}
-
-// buildGoTestCmd constructs the exec.Cmd for running `go test -json` with the
-// given env vars. When not root it wraps the command with `sudo env KEY=VAL …`
-// so elevated integration tests inherit the correct CGO/LIBBPFGO environment.
-func buildGoTestCmd(env map[string]string, cmdArgs []string) *exec.Cmd {
- if os.Geteuid() == 0 {
- cmd := exec.Command("go", cmdArgs...)
- cmd.Env = append(os.Environ(), envToList(env)...)
- return cmd
- }
- keys := make([]string, 0, len(env))
- for k := range env {
- keys = append(keys, k)
- }
- slices.Sort(keys)
- sudoArgs := make([]string, 0, 1+len(keys)+1+len(cmdArgs))
- sudoArgs = append(sudoArgs, "env")
- for _, k := range keys {
- sudoArgs = append(sudoArgs, k+"="+env[k])
- }
- sudoArgs = append(sudoArgs, "go")
- sudoArgs = append(sudoArgs, cmdArgs...)
- return exec.Command("sudo", sudoArgs...)
-}
-
-// startProgressTicker prints the set of currently-running tests every 15 s.
-// Call close(done) to stop the ticker goroutine.
-func startProgressTicker(running map[string]time.Time, done <-chan struct{}) {
- ticker := time.NewTicker(15 * time.Second)
- go func() {
- defer ticker.Stop()
- for {
- select {
- case <-done:
- return
- case <-ticker.C:
- if len(running) == 0 {
- fmt.Println("Integration tests still running... waiting for next test event")
- continue
- }
- names := make([]string, 0, len(running))
- for k := range running {
- names = append(names, k)
- }
- slices.Sort(names)
- fmt.Println("Integration tests running:", strings.Join(names, ", "))
- }
- }
- }()
-}
-
-// drainTestEvents reads JSON test events from scanner, updates the running map,
-// and prints human-readable RUN/PASS/FAIL/SKIP/LOG lines.
-func drainTestEvents(scanner *bufio.Scanner, running map[string]time.Time) {
- for scanner.Scan() {
- line := scanner.Bytes()
- var ev goTestEvent
- if err := json.Unmarshal(line, &ev); err != nil {
- fmt.Println(string(line))
- continue
- }
- if ev.Test == "" {
- continue
- }
- key := ev.Package + "/" + ev.Test
- switch ev.Action {
- case "run":
- running[key] = time.Now()
- fmt.Println("RUN ", key)
- case "pass":
- delete(running, key)
- fmt.Println("PASS", key)
- case "fail":
- delete(running, key)
- fmt.Println("FAIL", key)
- case "skip":
- delete(running, key)
- fmt.Println("SKIP", key)
- case "output":
- msg := strings.TrimSpace(ev.Output)
- if msg != "" && shouldPrintTestLog(msg) {
- fmt.Println("LOG ", key, "-", msg)
- }
- }
- }
-}
-
-// runGoTestWithProgress runs `go test -json` (via sudo when not root), streams
-// progress to stdout every 15 s, and returns a non-nil error on test failure.
-func runGoTestWithProgress(env map[string]string, args ...string) error {
- cmd := buildGoTestCmd(env, append([]string{"test"}, args...))
-
- stdout, err := cmd.StdoutPipe()
- if err != nil {
- return err
- }
- stderr, err := cmd.StderrPipe()
- if err != nil {
- return err
- }
- if err := cmd.Start(); err != nil {
- return err
- }
-
- // Forward stderr from the test binary so build errors are always visible.
- go func() { _, _ = io.Copy(os.Stderr, stderr) }()
-
- scanner := bufio.NewScanner(stdout)
- scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
-
- running := map[string]time.Time{}
- done := make(chan struct{})
- startProgressTicker(running, done)
-
- drainTestEvents(scanner, running)
- close(done)
-
- if err := scanner.Err(); err != nil {
- return err
- }
- return cmd.Wait()
-}
-
-func envToList(env map[string]string) []string {
- if len(env) == 0 {
- return nil
- }
- out := make([]string, 0, len(env))
- for k, v := range env {
- out = append(out, k+"="+v)
- }
- slices.Sort(out)
- return out
-}
-
-func shouldPrintTestLog(msg string) bool {
- // Always keep error/failure lines.
- if strings.Contains(msg, "--- FAIL:") ||
- strings.Contains(msg, " FAIL ") ||
- strings.Contains(msg, "panic:") ||
- strings.Contains(strings.ToLower(msg), "error") ||
- strings.Contains(strings.ToLower(msg), "expected event not found") {
- return true
- }
-
- // Drop high-volume attach/debug noise from ior startup in integration tests.
- noisePrefixes := []string{
- "=== RUN",
- "___",
- "|_ _|",
- "| |",
- "|___",
- "v0.0.0",
- "libbpf:",
- "Attaching tracepoint ",
- "Attached prog handle_ ",
- "Attached tracepoint",
- "Attaching sys_",
- "Not attaching sys_",
- "Collecting flame graph stats",
- "Starting flamegraph worker",
- "Waiting for stats to be ready",
- "Stopping event loop",
- "Waiting for flamegraph",
- "Worker ",
- "Writing ",
- "Good bye...",
- "Statistics:",
- "duration:",
- "tracepoints:",
- "syscalls:",
- "syscalls after filter:",
- }
- for _, p := range noisePrefixes {
- if strings.HasPrefix(msg, p) {
- return false
- }
- }
- return true
-}
-
func isIntegrationTest(testName string) (bool, error) {
out, err := sh.OutputWith(goEnv(), "go", "test", "./integrationtests/...", "-list", ".")
if err != nil {
diff --git a/docs/sudo-hardening-plan.md b/docs/sudo-hardening-plan.md
new file mode 100644
index 0000000..9610d01
--- /dev/null
+++ b/docs/sudo-hardening-plan.md
@@ -0,0 +1,108 @@
+# Sudo Hardening Plan for I/O Riot NG (ior)
+
+## Problem Statement
+
+The current build system (`Magefile.go`) silently wraps entire `go test` invocations—and several generation subcommands—with `sudo` when the invoking user is not root. This means:
+
+1. **You must trust `mage`** (an arbitrary-code build tool) with root privileges.
+2. **An attacker who can modify `Magefile.go`** (or any helper it imports) gets immediate privilege escalation.
+3. **It is impossible to audit what actually ran as root**, because the `sudo` command is assembled dynamically inside Go code.
+
+## Goal
+
+Run `mage` **entirely as an unprivileged user**. Only the **minimal set of specific sub-processes** that genuinely require elevated privileges (`CAP_BPF` / root) should ever run under `sudo`, and they should be invoked with `sudo -n` so no interactive password prompt can be exploited by a malicious build script.
+
+Granular `/etc/sudoers.d/` rules can then grant **password-less, command-locked** access to exactly those subprocesses.
+
+## Threat Model
+
+| Asset | Risk | Mitigation |
+|---|---|---|
+| `Magefile.go` | Malicious code injection → arbitrary root | `mage` never runs as root; `sudo` is only used for bounded, hard-coded subcommands |
+| `go test` | `init()`, `_testmain.go`, or imported packages run arbitrary code at startup | Integration tests are compiled to a static binary first; only the final binary runs under `sudo` |
+| `/sys/kernel/tracing` | Leak of kernel tracepoint data | Read-only access to public tracepoint format files (already world-readable on many systems, but the path traversal needs root on locked-down kernels) |
+| `bpftool` | Arbitrary BPF object loading | Restricted to the single read-only invocation: `bpftool btf dump file /sys/kernel/btf/vmlinux format c` |
+| `ior` binary | Full `CAP_BPF` access | Acceptable because it is the product being tested; access is via a compiled test binary with a deterministic path |
+
+## What Needs Root (and Why)
+
+| Operation | Needs root? | Reason |
+|---|---|---|
+| Compiling Go / BPF object | **No** | Pure toolchain work |
+| `mage test` (unit tests) | **No** | Mocks / stubs; no real BPF attachment |
+| `mage generate` / `mage world` | **Partially** | `bpftool btf dump ...` and reading `/sys/kernel/tracing/events/syscalls/*/format` |
+| `mage integrationTest` | **Yes** | Spawns `./ior`, which attaches BPF tracepoints |
+| `mage integrationTestSerial` | **Yes** | Same |
+| `mage demo` / `mage demoOne` | **Yes** | Tapes launch `./ior` (BPF) and `pkill` |
+| `mage installDemoTools` | **Yes** | `dnf install` system-wide package |
+
+## Design Principles
+
+1. **Compile as user, run binary as root.**
+ - Build the integration-test binary (`go test -c`) while fully unprivileged.
+ - Only the resulting `integrationtests.test` binary runs under `sudo`.
+2. **Explicit `sudo -n` for every elevated command.**
+ - No silent wrapping.
+ - If `sudo -n` fails, the build fails with a clear error telling the admin which sudo rule is missing.
+3. **Hard-code elevated commands; no dynamic construction.**
+ - The exact command line passed to `sudo` must be easily auditable in `Magefile.go`.
+4. **Sudoers rules are command-granular.**
+ - Each rule locks the user to a single command with exact arguments (or wildcards only where strictly necessary).
+
+## Changes to `Magefile.go`
+
+### 1. Remove automatic `sudo` wrapping from `buildGoTestCmd`
+
+Current behavior: when `os.Geteuid() != 0`, the function manufactures a `sudo env … go test …` command. **Delete this logic.** Going forward `buildGoTestCmd` returns a plain `go` command with no privilege elevation.
+
+### 2. Add compiled-binary helper for integration tests
+
+Two new helpers are introduced:
+
+- `compileIntegrationTestBinary(env)` – runs `go test -c ./integrationtests/...` with the required `CGO_*` environment.
+- `runIntegrationTestBinary(env, args…)` – elevates **only** the compiled binary via `sudo -n -E ./integrationtests.test …`, running it from the `integrationtests/` directory so that relative path resolution (`../ior`, `../ioworkload`) matches `go test` behaviour.
+
+`IntegrationTest`, `IntegrationTestSerial`, and `testWithName` (when the target is an integration test) are updated to:
+
+1. build `ior`, `ioworkload`, and compile the test binary as an unprivileged user;
+2. invoke `runIntegrationTestBinary` to execute the tests under `sudo`.
+
+### 3. Harden existing `sudo*` helpers
+
+- `sudoOutput` is updated to prefix every elevated call with `sudo -n`.
+- `sudoRunWithEnv` is updated to prefix every elevated call with `sudo -n env …`.
+- `ensureSudoTimestamp` and `startSudoKeepalive` are **retained but restricted** to the `Demo` targets (they are harmless there, and the demo still needs a warm sudo timestamp).
+
+### 4. What stays the same
+
+- `mage build`, `mage test`, `mage testRace`, `mage generateTracepointsGo`, `mage generateTypesGo` – no root required.
+- `ensureVMLINUX` and `readSyscallFormats` already call `sudoOutput` for discrete commands; they remain elevated, but now via `sudo -n`.
+
+## Impact on Existing Workflows
+
+| Before | After |
+|---|---|
+| `mage integrationTest` as non-root → auto `sudo env … go test …` | `mage integrationTest` as non-root → compiles as user, then `sudo -n -E ./integrationtests.test …` |
+| Password prompt may appear mid-build | `sudo -n` fails fast if rule missing; no surprise prompts |
+| JSON progress ticker from `go test -json` | Dropped for integration-test path; plain `-test.v` style output is printed directly. (The compiled test binary does not support the `go test` JSON protocol.) |
+| `mage testWithName TEST_NAME=TestAioSetup` | Same compiled-binary dance when target is an integration test |
+| `mage generate` | Unchanged UX; `sudo -n bpftool …` and `sudo -n sh -c 'cat /sys/kernel/tracing/…'` run automatically if sudoers rules exist |
+
+## Files Modified
+
+- `Magefile.go` – removes implicit sudo wrapping for `go test`; adds compiled-binary helpers.
+- `docs/sudo-rules-for-ior.txt` – copy-paste rules for `/etc/sudoers.d/`.
+
+## Deployment Steps for the Admin
+
+1. As **root**, copy `docs/sudo-rules-for-ior.txt` into `/etc/sudoers.d/ior`.
+2. Replace `%developers` with the actual Unix group or usernames that need to run tests.
+3. Validate syntax: `visudo -c -f /etc/sudoers.d/ior`.
+4. As **unprivileged user**, verify: `sudo -n bpftool btf dump file /sys/kernel/btf/vmlinux format c | head -3`.
+5. Run `mage integrationTest` as the unprivileged user.
+
+## Known Limitations
+
+- **`mage demo`** is **not covered** by the granular rules. The demo tapes run a helper script (`run-tape.sh`) that performs multiple privileged actions (`pkill`, launching `./ior`). Running the demo still requires a broad `NOPASSWD` rule for that script, or running `mage demo` as root.
+- **`mage installDemoTools`** requires `dnf` access and remains outside the scope of automated test rules.
+- The compiled integration-test binary is invoked from the **repository root**. Sudoers rules using a wildcard path (`…/ior/integrationtests.test`) assume the repository is checked out somewhere predictable (e.g., `/home/<user>/git/ior/`). If the checkout lives in arbitrary locations, use a wrapper script at a fixed path.
diff --git a/docs/sudo-rules-for-ior.txt b/docs/sudo-rules-for-ior.txt
new file mode 100644
index 0000000..07ae9e5
--- /dev/null
+++ b/docs/sudo-rules-for-ior.txt
@@ -0,0 +1,88 @@
+# =============================================================================
+# Sudoers rules for I/O Riot NG (ior) build/test hardening
+# =============================================================================
+# Purpose:
+# Allow unprivileged users to run ONLY the specific commands that require
+# root/CAP_BPF, while keeping Mage (the build tool) itself unprivileged.
+#
+# Installation (as root):
+# 1. Replace "%developers" below with the actual Unix group or username.
+# 2. Copy this file to /etc/sudoers.d/ior
+# 3. chmod 440 /etc/sudoers.d/ior
+# 4. visudo -c -f /etc/sudoers.d/ior
+#
+# Rules are ordered from most specific to least specific.
+# Use "!env_reset" or pass env vars explicitly via sudo's env_keep if needed.
+# =============================================================================
+
+# -----------------------------------------------------------------------------
+# 1. Read-only kernel BTF dump (used by mage generate / mage world)
+# -----------------------------------------------------------------------------
+# The exact invocation in Magefile.go is:
+# sudo -n bpftool btf dump file /sys/kernel/btf/vmlinux format c
+%developers ALL=(root) NOPASSWD: /usr/sbin/bpftool btf dump file /sys/kernel/btf/vmlinux format c
+
+# -----------------------------------------------------------------------------
+# 2. Read tracepoint format files (used by mage generate / mage world)
+# -----------------------------------------------------------------------------
+# The exact invocation in Magefile.go is:
+# sudo -n sh -c 'LC_ALL=C find /sys/kernel/tracing/events/syscalls \
+# -maxdepth 2 -mindepth 2 -name format | sort | xargs cat'
+# Because the shell expands the command, we whitelist /bin/sh with the exact
+# argument string. Adjust the path to /bin/sh or /usr/bin/sh as appropriate.
+%developers ALL=(root) NOPASSWD: /bin/sh -c LC_ALL\=C find /sys/kernel/tracing/events/syscalls -maxdepth 2 -mindepth 2 -name format | sort | xargs cat
+
+# -----------------------------------------------------------------------------
+# 3. Run the compiled integration test binary (used by mage integrationTest,
+# mage integrationTestSerial, mage testWithName for integration targets)
+# -----------------------------------------------------------------------------
+# The binary is produced by "go test -c ./integrationtests" and lands in the
+# repository root as "integrationtests.test". Because Go produces a static
+# binary at a predictable name, we lock the rule to that path.
+#
+# If your repo is checked out in a non-standard location, you may need a
+# symlink or wrapper script at a fixed path (e.g. /usr/local/bin/ior-test-runner)
+# and update Magefile.go accordingly.
+#
+# IMPORTANT: we must preserve the environment (CGO_CFLAGS, CGO_LDFLAGS, LIBBPFGO,
+# HOME, PATH, etc.) that mage sets before invoking sudo. Sudoers does not allow
+# arbitrary env preservation by default, but "SETENV" on the Cmnd_Spec enables
+# the caller to preserve the environment via "sudo -n -E ./binary".
+#
+# Alternatively, add the needed env vars to env_keep in your main sudoers file
+# and omit SETENV here. The minimal set is: CGO_CFLAGS, CGO_LDFLAGS, GOARCH,
+# GOOS, LIBBPFGO, HOME, GOPATH, GOMODCACHE, PATH, GOTOOLCHAIN.
+%developers ALL=(root) NOPASSWD:SETENV: /home/*/*/ior/integrationtests.test
+
+# If you prefer a tighter path, uncomment and adjust the line below instead:
+# %developers ALL=(root) NOPASSWD:SETENV: /home/paul/git/ior/integrationtests.test
+
+# -----------------------------------------------------------------------------
+# 4. Run the ior binary itself (optional — only if you want to run ior via sudo
+# from scripts outside of mage)
+# -----------------------------------------------------------------------------
+%developers ALL=(root) NOPASSWD:SETENV: /home/*/*/ior/ior
+
+# -----------------------------------------------------------------------------
+# 5. Demo tooling (OUT OF SCOPE for granular rules)
+# -----------------------------------------------------------------------------
+# The demo pipeline (mage demo / mage demoOne) spawns a shell script that calls
+# sudo multiple times for pkill, ttyd, and launching ior. This is inherently
+# broader and should be handled separately—either by running the demo as root,
+# or by creating a dedicated demo-user with a broader but still locked-down
+# sudoers snippet.
+#
+# Example BROADER rule (use with caution):
+# %developers ALL=(root) NOPASSWD: /bin/bash /home/*/*/ior/docs/tutorial/scripts/run-tape.sh *
+# %developers ALL=(root) NOPASSWD: /usr/bin/pkill -TERM -f /ior$
+# %developers ALL=(root) NOPASSWD: /usr/bin/ttyd *
+
+# -----------------------------------------------------------------------------
+# 6. Package installation (OUT OF SCOPE — admin task only)
+# -----------------------------------------------------------------------------
+# mage installDemoTools runs "sudo dnf install -y ttyd". This is a one-time
+# admin operation and should NOT be included in automated CI/test sudoers.
+
+# =============================================================================
+# END OF FILE
+# =============================================================================