Compare commits

..

No commits in common. "82b28c589a56b2690a7de4f5f43ab39a26174952" and "d58fb8c6eee659e4a71c4d69bfb8539199b431bd" have entirely different histories.

43 changed files with 900 additions and 1057 deletions

View File

@ -1,11 +1,9 @@
package dbus_test package dbus_test
import ( import (
"context"
"errors" "errors"
"strings" "strings"
"testing" "testing"
"time"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
@ -143,7 +141,7 @@ func testProxyStartWaitCloseString(t *testing.T, sandbox bool) {
t.Run("unsealed start of "+id, func(t *testing.T) { t.Run("unsealed start of "+id, func(t *testing.T) {
want := "proxy not sealed" want := "proxy not sealed"
if err := p.Start(context.Background(), nil, sandbox); err == nil || err.Error() != want { if err := p.Start(nil, nil, sandbox, false); err == nil || err.Error() != want {
t.Errorf("Start() error = %v, wantErr %q", t.Errorf("Start() error = %v, wantErr %q",
err, errors.New(want)) err, errors.New(want))
return return
@ -151,7 +149,7 @@ func testProxyStartWaitCloseString(t *testing.T, sandbox bool) {
}) })
t.Run("unsealed wait of "+id, func(t *testing.T) { t.Run("unsealed wait of "+id, func(t *testing.T) {
wantErr := "dbus: not started" wantErr := "proxy not started"
if err := p.Wait(); err == nil || err.Error() != wantErr { if err := p.Wait(); err == nil || err.Error() != wantErr {
t.Errorf("Wait() error = %v, wantErr %v", t.Errorf("Wait() error = %v, wantErr %v",
err, wantErr) err, wantErr)
@ -177,10 +175,7 @@ func testProxyStartWaitCloseString(t *testing.T, sandbox bool) {
} }
t.Run("sealed start of "+id, func(t *testing.T) { t.Run("sealed start of "+id, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) if err := p.Start(nil, output, sandbox, false); err != nil {
defer cancel()
if err := p.Start(ctx, output, sandbox); err != nil {
t.Fatalf("Start(nil, nil) error = %v", t.Fatalf("Start(nil, nil) error = %v",
err) err)
} }
@ -194,8 +189,22 @@ func testProxyStartWaitCloseString(t *testing.T, sandbox bool) {
} }
}) })
t.Run("sealed closing of "+id+" without status", func(t *testing.T) {
wantPanic := "attempted to close helper with no status pipe"
defer func() {
if r := recover(); r != wantPanic {
t.Errorf("Close() panic = %v, wantPanic %v",
r, wantPanic)
}
}()
if err := p.Close(); err != nil {
t.Errorf("Close() error = %v",
err)
}
})
t.Run("started wait of "+id, func(t *testing.T) { t.Run("started wait of "+id, func(t *testing.T) {
p.Close()
if err := p.Wait(); err != nil { if err := p.Wait(); err != nil {
t.Errorf("Wait() error = %v\noutput: %s", t.Errorf("Wait() error = %v\noutput: %s",
err, output.String()) err, output.String())

View File

@ -1,7 +1,6 @@
package dbus package dbus
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -20,21 +19,29 @@ var ProxyName = "xdg-dbus-proxy"
type Proxy struct { type Proxy struct {
helper helper.Helper helper helper.Helper
bwrap *bwrap.Config bwrap *bwrap.Config
ctx context.Context
cancel context.CancelCauseFunc
name string name string
session [2]string session [2]string
system [2]string system [2]string
sysP bool
seal io.WriterTo seal io.WriterTo
lock sync.RWMutex lock sync.RWMutex
} }
func (p *Proxy) Session() [2]string { return p.session } func (p *Proxy) Session() [2]string {
func (p *Proxy) System() [2]string { return p.system } return p.session
func (p *Proxy) Sealed() bool { p.lock.RLock(); defer p.lock.RUnlock(); return p.seal != nil } }
func (p *Proxy) System() [2]string {
return p.system
}
func (p *Proxy) Sealed() bool {
p.lock.RLock()
defer p.lock.RUnlock()
return p.seal != nil
}
var ( var (
ErrConfig = errors.New("no configuration to seal") ErrConfig = errors.New("no configuration to seal")
@ -49,7 +56,7 @@ func (p *Proxy) String() string {
defer p.lock.RUnlock() defer p.lock.RUnlock()
if p.helper != nil { if p.helper != nil {
return p.helper.String() return p.helper.Unwrap().String()
} }
if p.seal != nil { if p.seal != nil {
@ -59,14 +66,7 @@ func (p *Proxy) String() string {
return "(unsealed dbus proxy)" return "(unsealed dbus proxy)"
} }
// BwrapStatic builds static bwrap args. This omits any fd-dependant args.
func (p *Proxy) BwrapStatic() []string { func (p *Proxy) BwrapStatic() []string {
p.lock.RLock()
defer p.lock.RUnlock()
if p.bwrap == nil {
return nil
}
return p.bwrap.Args() return p.bwrap.Args()
} }
@ -89,7 +89,6 @@ func (p *Proxy) Seal(session, system *Config) error {
} }
if system != nil { if system != nil {
args = append(args, system.Args(p.system)...) args = append(args, system.Args(p.system)...)
p.sysP = true
} }
if seal, err := helper.NewCheckedArgs(args); err != nil { if seal, err := helper.NewCheckedArgs(args); err != nil {
return err return err

View File

@ -1,10 +1,8 @@
package dbus package dbus
import ( import (
"context"
"errors" "errors"
"io" "io"
"os"
"os/exec" "os/exec"
"path" "path"
"path/filepath" "path/filepath"
@ -16,8 +14,9 @@ import (
"git.gensokyo.uk/security/fortify/ldd" "git.gensokyo.uk/security/fortify/ldd"
) )
// Start launches the D-Bus proxy. // Start launches the D-Bus proxy and sets up the Wait method.
func (p *Proxy) Start(ctx context.Context, output io.Writer, sandbox bool) error { // ready should be buffered and must only be received from once.
func (p *Proxy) Start(ready chan error, output io.Writer, sandbox, seccomp bool) error {
p.lock.Lock() p.lock.Lock()
defer p.lock.Unlock() defer p.lock.Unlock()
@ -27,6 +26,7 @@ func (p *Proxy) Start(ctx context.Context, output io.Writer, sandbox bool) error
var ( var (
h helper.Helper h helper.Helper
cmd *exec.Cmd
argF = func(argsFD, statFD int) []string { argF = func(argsFD, statFD int) []string {
if statFD == -1 { if statFD == -1 {
@ -39,8 +39,9 @@ func (p *Proxy) Start(ctx context.Context, output io.Writer, sandbox bool) error
if !sandbox { if !sandbox {
h = helper.New(p.seal, p.name, argF) h = helper.New(p.seal, p.name, argF)
cmd = h.Unwrap()
// xdg-dbus-proxy does not need to inherit the environment // xdg-dbus-proxy does not need to inherit the environment
h.SetEnv(make([]string, 0)) cmd.Env = []string{}
} else { } else {
// look up absolute path if name is just a file name // look up absolute path if name is just a file name
toolPath := p.name toolPath := p.name
@ -55,7 +56,7 @@ func (p *Proxy) Start(ctx context.Context, output io.Writer, sandbox bool) error
// resolve libraries by parsing ldd output // resolve libraries by parsing ldd output
var proxyDeps []*ldd.Entry var proxyDeps []*ldd.Entry
if toolPath != "/nonexistent-xdg-dbus-proxy" { if toolPath != "/nonexistent-xdg-dbus-proxy" {
if l, err := ldd.Exec(ctx, toolPath); err != nil { if l, err := ldd.Exec(toolPath); err != nil {
return err return err
} else { } else {
proxyDeps = l proxyDeps = l
@ -72,6 +73,10 @@ func (p *Proxy) Start(ctx context.Context, output io.Writer, sandbox bool) error
DieWithParent: true, DieWithParent: true,
} }
if !seccomp {
bc.Syscall = nil
}
// resolve proxy socket directories // resolve proxy socket directories
bindTarget := make(map[string]struct{}, 2) bindTarget := make(map[string]struct{}, 2)
for _, ps := range []string{p.session[1], p.system[1]} { for _, ps := range []string{p.session[1], p.system[1]} {
@ -111,65 +116,35 @@ func (p *Proxy) Start(ctx context.Context, output io.Writer, sandbox bool) error
} }
h = helper.MustNewBwrap(bc, toolPath, p.seal, argF, nil, nil) h = helper.MustNewBwrap(bc, toolPath, p.seal, argF, nil, nil)
cmd = h.Unwrap()
p.bwrap = bc p.bwrap = bc
} }
if output != nil { if output != nil {
h.Stdout(output).Stderr(output) cmd.Stdout = output
cmd.Stderr = output
} }
c, cancel := context.WithCancelCause(ctx) if err := h.StartNotify(ready); err != nil {
if err := h.Start(c, true); err != nil {
cancel(err)
return err return err
} }
p.helper = h p.helper = h
p.ctx = c
p.cancel = cancel
return nil return nil
} }
var proxyClosed = errors.New("proxy closed") // Wait waits for xdg-dbus-proxy to exit or fault.
// Wait blocks until xdg-dbus-proxy exits and releases resources.
func (p *Proxy) Wait() error { func (p *Proxy) Wait() error {
p.lock.RLock() p.lock.RLock()
defer p.lock.RUnlock() defer p.lock.RUnlock()
if p.helper == nil { if p.helper == nil {
return errors.New("dbus: not started") return errors.New("proxy not started")
} }
errs := make([]error, 3) return p.helper.Wait()
errs[0] = p.helper.Wait()
if p.cancel == nil &&
errors.Is(errs[0], context.Canceled) &&
errors.Is(context.Cause(p.ctx), proxyClosed) {
errs[0] = nil
} }
// ensure socket removal so ephemeral directory is empty at revert // Close closes the status file descriptor passed to xdg-dbus-proxy, causing it to stop.
if err := os.Remove(p.session[1]); err != nil && !errors.Is(err, os.ErrNotExist) { func (p *Proxy) Close() error {
errs[1] = err return p.helper.Close()
}
if p.sysP {
if err := os.Remove(p.system[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
errs[2] = err
}
}
return errors.Join(errs...)
}
// Close cancels the context passed to the helper instance attached to xdg-dbus-proxy.
func (p *Proxy) Close() {
p.lock.Lock()
defer p.lock.Unlock()
if p.cancel == nil {
panic("dbus: not started")
}
p.cancel(proxyClosed)
p.cancel = nil
} }

6
flake.lock generated
View File

@ -23,11 +23,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1739333913, "lastModified": 1737672001,
"narHash": "sha256-JXt5FtySR+yBm5ny8zG/hX1IybF/7R66jZfXxXSb6wY=", "narHash": "sha256-YnHJJ19wqmibLQdUeq9xzE6CjrMA568KN/lFPuSVs4I=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "7d83f668aee9e41d574c398a9bb569047e8a3f5d", "rev": "035f8c0853c2977b24ffc4d0a42c74f00b182cd8",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@ -1,47 +1,111 @@
package helper package helper
import ( import (
"context"
"errors" "errors"
"io" "io"
"os" "os"
"slices" "os/exec"
"strconv" "strconv"
"sync" "sync"
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/helper/proc"
) )
// BubblewrapName is the file name or path to bubblewrap. // BubblewrapName is the file name or path to bubblewrap.
var BubblewrapName = "bwrap" var BubblewrapName = "bwrap"
type bubblewrap struct { type bubblewrap struct {
// final args fd of bwrap process // bwrap child file name
argsFd uintptr
// name of the command to run in bwrap
name string name string
// bwrap pipes
control *pipes
// returns an array of arguments passed directly
// to the child process spawned by bwrap
argF func(argsFD, statFD int) []string
// pipes received by the child
// nil if no pipes are required
controlPt *pipes
lock sync.RWMutex lock sync.RWMutex
*helperCmd *exec.Cmd
} }
func (b *bubblewrap) Start(ctx context.Context, stat bool) error { func (b *bubblewrap) StartNotify(ready chan error) error {
b.lock.Lock() b.lock.Lock()
defer b.lock.Unlock() defer b.lock.Unlock()
if ready != nil && b.controlPt == nil {
panic("attempted to start with status monitoring on a bwrap child initialised without pipes")
}
// Check for doubled Start calls before we defer failure cleanup. If the prior // Check for doubled Start calls before we defer failure cleanup. If the prior
// call to Start succeeded, we don't want to spuriously close its pipes. // call to Start succeeded, we don't want to spuriously close its pipes.
if b.Cmd != nil && b.Cmd.Process != nil { if b.Cmd.Process != nil {
return errors.New("exec: already started") return errors.New("exec: already started")
} }
args := b.finalise(ctx, stat) // prepare bwrap pipe and args
b.Cmd.Args = slices.Grow(b.Cmd.Args, 4+len(args)) if argsFD, _, err := b.control.prepareCmd(b.Cmd); err != nil {
b.Cmd.Args = append(b.Cmd.Args, "--args", strconv.Itoa(int(b.argsFd)), "--", b.name) return err
b.Cmd.Args = append(b.Cmd.Args, args...) } else {
return proc.Fulfill(ctx, b.Cmd, b.files, b.extraFiles) b.Cmd.Args = append(b.Cmd.Args, "--args", strconv.Itoa(argsFD), "--", b.name)
}
// prepare child args and pipes if enabled
if b.controlPt != nil {
b.controlPt.ready = ready
if argsFD, statFD, err := b.controlPt.prepareCmd(b.Cmd); err != nil {
return err
} else {
b.Cmd.Args = append(b.Cmd.Args, b.argF(argsFD, statFD)...)
}
} else {
b.Cmd.Args = append(b.Cmd.Args, b.argF(-1, -1)...)
}
if ready != nil {
b.Cmd.Env = append(b.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=1")
} else if b.controlPt != nil {
b.Cmd.Env = append(b.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=0")
} else {
b.Cmd.Env = append(b.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=-1")
}
if err := b.Cmd.Start(); err != nil {
return err
}
// write bwrap args first
if err := b.control.readyWriteArgs(); err != nil {
return err
}
// write child args if enabled
if b.controlPt != nil {
if err := b.controlPt.readyWriteArgs(); err != nil {
return err
}
}
return nil
}
func (b *bubblewrap) Close() error {
if b.controlPt == nil {
panic("attempted to close bwrap child initialised without pipes")
}
return b.controlPt.closeStatus()
}
func (b *bubblewrap) Start() error {
return b.StartNotify(nil)
}
func (b *bubblewrap) Unwrap() *exec.Cmd {
return b.Cmd
} }
// MustNewBwrap initialises a new Bwrap instance with wt as the null-terminated argument writer. // MustNewBwrap initialises a new Bwrap instance with wt as the null-terminated argument writer.
@ -66,23 +130,27 @@ func MustNewBwrap(
// Function argF returns an array of arguments passed directly to the child process. // Function argF returns an array of arguments passed directly to the child process.
func NewBwrap( func NewBwrap(
conf *bwrap.Config, name string, conf *bwrap.Config, name string,
wt io.WriterTo, argF func(argsFd, statFd int) []string, wt io.WriterTo, argF func(argsFD, statFD int) []string,
extraFiles []*os.File, extraFiles []*os.File,
syncFd *os.File, syncFd *os.File,
) (Helper, error) { ) (Helper, error) {
b := new(bubblewrap) b := new(bubblewrap)
b.argF = argF
b.name = name b.name = name
b.helperCmd = newHelperCmd(b, BubblewrapName, wt, argF, extraFiles) if wt != nil {
b.controlPt = &pipes{args: wt}
}
b.Cmd = execCommand(BubblewrapName)
b.control = new(pipes)
args := conf.Args() args := conf.Args()
conf.FDArgs(syncFd, &args, b.extraFiles, &b.files) if fdArgs, err := conf.FDArgs(syncFd, &extraFiles); err != nil {
if v, err := NewCheckedArgs(args); err != nil { return nil, err
} else if b.control.args, err = NewCheckedArgs(append(args, fdArgs...)); err != nil {
return nil, err return nil, err
} else { } else {
f := proc.NewWriterTo(v) b.Cmd.ExtraFiles = extraFiles
b.argsFd = proc.InitFile(f, b.extraFiles)
b.files = append(b.files, f)
} }
return b, nil return b, nil

View File

@ -1,10 +1,12 @@
package bwrap package bwrap
import ( import (
"encoding/gob"
"os" "os"
"slices" "slices"
"strconv"
"git.gensokyo.uk/security/fortify/helper/proc" "git.gensokyo.uk/security/fortify/internal/proc"
) )
type Builder interface { type Builder interface {
@ -18,8 +20,68 @@ type FSBuilder interface {
} }
type FDBuilder interface { type FDBuilder interface {
proc.File Len() int
Builder Append(args *[]string, extraFiles *[]*os.File) error
}
func init() {
gob.Register(new(pairF))
gob.Register(new(stringF))
}
type pairF [3]string
func (p *pairF) Path() string {
return p[2]
}
func (p *pairF) Len() int {
return len(p) // compiler replaces this with 3
}
func (p *pairF) Append(args *[]string) {
*args = append(*args, p[0], p[1], p[2])
}
type stringF [2]string
func (s stringF) Path() string {
return s[1]
}
func (s stringF) Len() int {
return len(s) // compiler replaces this with 2
}
func (s stringF) Append(args *[]string) {
*args = append(*args, s[0], s[1])
}
type fileF struct {
name string
file *os.File
}
func (f *fileF) Len() int {
if f.file == nil {
return 0
}
return 2
}
func (f *fileF) Append(args *[]string, extraFiles *[]*os.File) error {
if f.file == nil {
return nil
}
extraFile(args, extraFiles, f.name, f.file)
return nil
}
func extraFile(args *[]string, extraFiles *[]*os.File, name string, f *os.File) {
if f == nil {
return
}
*args = append(*args, name, strconv.Itoa(int(proc.ExtraFileSlice(extraFiles, f))))
} }
// Args returns a slice of bwrap args corresponding to c. // Args returns a slice of bwrap args corresponding to c.
@ -53,36 +115,24 @@ func (c *Config) Args() (args []string) {
return return
} }
func (c *Config) FDArgs(syncFd *os.File, args *[]string, extraFiles *proc.ExtraFilesPre, files *[]proc.File) { func (c *Config) FDArgs(syncFd *os.File, extraFiles *[]*os.File) (args []string, err error) {
builders := []FDBuilder{ builders := []FDBuilder{
c.seccompArgs(), &seccompBuilder{c},
newFile(positionalArgs[SyncFd], syncFd), &fileF{positionalArgs[SyncFd], syncFd},
} }
argc := 0 argc := 0
fc := 0
for _, b := range builders { for _, b := range builders {
l := b.Len() argc += b.Len()
if l < 1 {
continue
}
argc += l
fc++
proc.InitFile(b, extraFiles)
} }
fc++ // allocate extra slot for stat fd args = make([]string, 0, argc)
*args = slices.Grow(*args, argc) *extraFiles = slices.Grow(*extraFiles, len(builders))
*files = slices.Grow(*files, fc)
for _, b := range builders { for _, b := range builders {
if b.Len() < 1 { if err = b.Append(&args, extraFiles); err != nil {
continue break
} }
b.Append(args)
*files = append(*files, b)
} }
return return
} }

View File

@ -2,9 +2,8 @@ package bwrap
import ( import (
"fmt" "fmt"
"strconv" "os"
"git.gensokyo.uk/security/fortify/helper/proc"
"git.gensokyo.uk/security/fortify/helper/seccomp" "git.gensokyo.uk/security/fortify/helper/seccomp"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
@ -24,13 +23,35 @@ type SyscallPolicy struct {
Bluetooth bool `json:"bluetooth"` Bluetooth bool `json:"bluetooth"`
} }
func (c *Config) seccompArgs() FDBuilder { type seccompBuilder struct {
// explicitly disable syscall filter config *Config
if c.Syscall == nil {
// nil File skips builder
return new(seccompBuilder)
} }
func (s *seccompBuilder) Len() int {
if s == nil {
return 0
}
return 2
}
func (s *seccompBuilder) Append(args *[]string, extraFiles *[]*os.File) error {
if s == nil {
return nil
}
if f, err := s.config.resolveSeccomp(); err != nil {
return err
} else {
extraFile(args, extraFiles, positionalArgs[Seccomp], f)
return nil
}
}
func (c *Config) resolveSeccomp() (*os.File, error) {
if c.Syscall == nil {
return nil, nil
}
// resolve seccomp filter opts
var ( var (
opts seccomp.SyscallOpts opts seccomp.SyscallOpts
optd []string optd []string
@ -65,22 +86,5 @@ func (c *Config) seccompArgs() FDBuilder {
seccomp.CPrintln(fmt.Sprintf("seccomp flags: %s", optd)) seccomp.CPrintln(fmt.Sprintf("seccomp flags: %s", optd))
} }
return &seccompBuilder{seccomp.NewFile(opts)} return seccomp.Export(opts)
}
type seccompBuilder struct{ proc.File }
func (s *seccompBuilder) Len() int {
if s == nil || s.File == nil {
return 0
}
return 2
}
func (s *seccompBuilder) Append(args *[]string) {
if s == nil || s.File == nil {
return
}
*args = append(*args, positionalArgs[Seccomp], strconv.Itoa(int(s.Fd())))
} }

View File

@ -1,52 +0,0 @@
package bwrap
import (
"context"
"encoding/gob"
"os"
"strconv"
"git.gensokyo.uk/security/fortify/helper/proc"
)
func init() {
gob.Register(new(pairF))
gob.Register(new(stringF))
}
type pairF [3]string
func (p *pairF) Path() string { return p[2] }
func (p *pairF) Len() int { return len(p) }
func (p *pairF) Append(args *[]string) { *args = append(*args, p[0], p[1], p[2]) }
type stringF [2]string
func (s stringF) Path() string { return s[1] }
func (s stringF) Len() int { return len(s) /* compiler replaces this with 2 */ }
func (s stringF) Append(args *[]string) { *args = append(*args, s[0], s[1]) }
func newFile(name string, f *os.File) FDBuilder { return &fileF{name: name, file: f} }
type fileF struct {
name string
file *os.File
proc.BaseFile
}
func (f *fileF) ErrCount() int { return 0 }
func (f *fileF) Fulfill(_ context.Context, _ func(error)) error { f.Set(f.file); return nil }
func (f *fileF) Len() int {
if f.file == nil {
return 0
}
return 2
}
func (f *fileF) Append(args *[]string) {
if f.file == nil {
return
}
*args = append(*args, f.name, strconv.Itoa(int(f.Fd())))
}

View File

@ -1,12 +1,11 @@
package helper_test package helper_test
import ( import (
"context"
"errors" "errors"
"fmt"
"os" "os"
"strings" "strings"
"testing" "testing"
"time"
"git.gensokyo.uk/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
@ -14,7 +13,9 @@ import (
func TestBwrap(t *testing.T) { func TestBwrap(t *testing.T) {
sc := &bwrap.Config{ sc := &bwrap.Config{
Unshare: nil,
Net: true, Net: true,
UserNS: false,
Hostname: "localhost", Hostname: "localhost",
Chdir: "/nonexistent", Chdir: "/nonexistent",
Clearenv: true, Clearenv: true,
@ -36,8 +37,8 @@ func TestBwrap(t *testing.T) {
nil, nil, nil, nil,
) )
if err := h.Start(context.Background(), false); !errors.Is(err, os.ErrNotExist) { if err := h.Start(); !errors.Is(err, os.ErrNotExist) {
t.Errorf("Start: error = %v, wantErr %v", t.Errorf("Start() error = %v, wantErr %v",
err, os.ErrNotExist) err, os.ErrNotExist)
} }
}) })
@ -70,6 +71,23 @@ func TestBwrap(t *testing.T) {
) )
}) })
t.Run("start notify without pipes panic", func(t *testing.T) {
defer func() {
wantPanic := "attempted to start with status monitoring on a bwrap child initialised without pipes"
if r := recover(); r != wantPanic {
t.Errorf("StartNotify: panic = %q, want %q",
r, wantPanic)
}
}()
panic(fmt.Sprintf("unreachable: %v",
helper.MustNewBwrap(
sc, "fortify",
nil, argF,
nil, nil,
).StartNotify(make(chan error))))
})
t.Run("start without pipes", func(t *testing.T) { t.Run("start without pipes", func(t *testing.T) {
helper.InternalReplaceExecCommand(t) helper.InternalReplaceExecCommand(t)
@ -78,15 +96,26 @@ func TestBwrap(t *testing.T) {
nil, argFChecked, nil, argFChecked,
nil, nil, nil, nil,
) )
cmd := h.Unwrap()
stdout, stderr := new(strings.Builder), new(strings.Builder) stdout, stderr := new(strings.Builder), new(strings.Builder)
h.Stdout(stdout).Stderr(stderr) cmd.Stdout, cmd.Stderr = stdout, stderr
c, cancel := context.WithTimeout(context.Background(), 5*time.Second) t.Run("close without pipes panic", func(t *testing.T) {
defer cancel() defer func() {
wantPanic := "attempted to close bwrap child initialised without pipes"
if r := recover(); r != wantPanic {
t.Errorf("Close: panic = %q, want %q",
r, wantPanic)
}
}()
if err := h.Start(c, false); err != nil { panic(fmt.Sprintf("unreachable: %v",
t.Errorf("Start: error = %v", h.Close()))
})
if err := h.Start(); err != nil {
t.Errorf("Start() error = %v",
err) err)
return return
} }

View File

@ -1,40 +1,93 @@
package helper package helper
import ( import (
"context"
"errors" "errors"
"io" "io"
"os/exec"
"sync" "sync"
"git.gensokyo.uk/security/fortify/helper/proc"
) )
// direct wraps *exec.Cmd and manages status and args fd. // direct wraps *exec.Cmd and manages status and args fd.
// Args is always 3 and status if set is always 4. // Args is always 3 and status if set is always 4.
type direct struct { type direct struct {
// helper pipes
// cannot be nil
p *pipes
// returns an array of arguments passed directly
// to the helper process
argF func(argsFD, statFD int) []string
lock sync.RWMutex lock sync.RWMutex
*helperCmd *exec.Cmd
} }
func (h *direct) Start(ctx context.Context, stat bool) error { func (h *direct) StartNotify(ready chan error) error {
h.lock.Lock() h.lock.Lock()
defer h.lock.Unlock() defer h.lock.Unlock()
// Check for doubled Start calls before we defer failure cleanup. If the prior // Check for doubled Start calls before we defer failure cleanup. If the prior
// call to Start succeeded, we don't want to spuriously close its pipes. // call to Start succeeded, we don't want to spuriously close its pipes.
if h.Cmd != nil && h.Cmd.Process != nil { if h.Cmd.Process != nil {
return errors.New("exec: already started") return errors.New("exec: already started")
} }
args := h.finalise(ctx, stat) h.p.ready = ready
h.Cmd.Args = append(h.Cmd.Args, args...) if argsFD, statFD, err := h.p.prepareCmd(h.Cmd); err != nil {
return proc.Fulfill(ctx, h.Cmd, h.files, h.extraFiles) return err
} else {
h.Cmd.Args = append(h.Cmd.Args, h.argF(argsFD, statFD)...)
}
if ready != nil {
h.Cmd.Env = append(h.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=1")
} else {
h.Cmd.Env = append(h.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=0")
}
if err := h.Cmd.Start(); err != nil {
return err
}
if err := h.p.readyWriteArgs(); err != nil {
return err
}
return nil
}
func (h *direct) Wait() error {
h.lock.RLock()
defer h.lock.RUnlock()
if h.Cmd.Process == nil {
return errors.New("exec: not started")
}
defer h.p.mustClosePipes()
if h.Cmd.ProcessState != nil {
return errors.New("exec: Wait was already called")
}
return h.Cmd.Wait()
}
func (h *direct) Close() error {
return h.p.closeStatus()
}
func (h *direct) Start() error {
return h.StartNotify(nil)
}
func (h *direct) Unwrap() *exec.Cmd {
return h.Cmd
} }
// New initialises a new direct Helper instance with wt as the null-terminated argument writer. // New initialises a new direct Helper instance with wt as the null-terminated argument writer.
// Function argF returns an array of arguments passed directly to the child process. // Function argF returns an array of arguments passed directly to the child process.
func New(wt io.WriterTo, name string, argF func(argsFd, statFd int) []string) Helper { func New(wt io.WriterTo, name string, argF func(argsFD, statFD int) []string) Helper {
d := new(direct) if wt == nil {
d.helperCmd = newHelperCmd(d, name, wt, argF, nil) panic("attempted to create helper with invalid argument writer")
return d }
return &direct{p: &pipes{args: wt}, argF: argF, Cmd: execCommand(name)}
} }

View File

@ -1,7 +1,6 @@
package helper_test package helper_test
import ( import (
"context"
"errors" "errors"
"os" "os"
"testing" "testing"
@ -13,8 +12,8 @@ func TestDirect(t *testing.T) {
t.Run("start non-existent helper path", func(t *testing.T) { t.Run("start non-existent helper path", func(t *testing.T) {
h := helper.New(argsWt, "/nonexistent", argF) h := helper.New(argsWt, "/nonexistent", argF)
if err := h.Start(context.Background(), false); !errors.Is(err, os.ErrNotExist) { if err := h.Start(); !errors.Is(err, os.ErrNotExist) {
t.Errorf("Start: error = %v, wantErr %v", t.Errorf("Start() error = %v, wantErr %v",
err, os.ErrNotExist) err, os.ErrNotExist)
} }
}) })
@ -27,6 +26,18 @@ func TestDirect(t *testing.T) {
} }
}) })
t.Run("invalid new helper panic", func(t *testing.T) {
defer func() {
wantPanic := "attempted to create helper with invalid argument writer"
if r := recover(); r != wantPanic {
t.Errorf("New: panic = %q, want %q",
r, wantPanic)
}
}()
helper.New(nil, "fortify", argF)
})
t.Run("implementation compliance", func(t *testing.T) { t.Run("implementation compliance", func(t *testing.T) {
testHelper(t, func() helper.Helper { return helper.New(argsWt, "crash-test-dummy", argF) }) testHelper(t, func() helper.Helper { return helper.New(argsWt, "crash-test-dummy", argF) })
}) })

View File

@ -2,133 +2,35 @@
package helper package helper
import ( import (
"context" "errors"
"fmt"
"io"
"os"
"os/exec" "os/exec"
"slices"
"syscall"
"time"
"git.gensokyo.uk/security/fortify/helper/proc"
) )
var ( var (
WaitDelay = 2 * time.Second ErrStatusFault = errors.New("generic status pipe fault")
ErrStatusRead = errors.New("unexpected status response")
) )
const ( const (
// FortifyHelper is set to 1 when args fd is enabled and 0 otherwise. // FortifyHelper is set for the process launched by Helper.
FortifyHelper = "FORTIFY_HELPER" FortifyHelper = "FORTIFY_HELPER"
// FortifyStatus is set to 1 when stat fd is enabled and 0 otherwise. // FortifyStatus is 1 when sync fd is enabled and 0 otherwise.
FortifyStatus = "FORTIFY_STATUS" FortifyStatus = "FORTIFY_STATUS"
) )
type Helper interface { type Helper interface {
// Stdin sets the standard input of Helper. // StartNotify starts the helper process.
Stdin(r io.Reader) Helper // A status pipe is passed to the helper if ready is not nil.
// Stdout sets the standard output of Helper. StartNotify(ready chan error) error
Stdout(w io.Writer) Helper
// Stderr sets the standard error of Helper.
Stderr(w io.Writer) Helper
// SetEnv sets the environment of Helper.
SetEnv(env []string) Helper
// Start starts the helper process. // Start starts the helper process.
// A status pipe is passed to the helper if stat is true. Start() error
Start(ctx context.Context, stat bool) error // Close closes the status pipe.
// Wait blocks until Helper exits and releases all its resources. // If helper is started without the status pipe, Close panics.
Close() error
// Wait calls wait on the child process and cleans up pipes.
Wait() error Wait() error
// Unwrap returns the underlying exec.Cmd instance.
fmt.Stringer Unwrap() *exec.Cmd
} }
func newHelperCmd( var execCommand = exec.Command
h Helper, name string,
wt io.WriterTo, argF func(argsFd, statFd int) []string,
extraFiles []*os.File,
) (cmd *helperCmd) {
cmd = new(helperCmd)
cmd.r = h
cmd.name = name
cmd.extraFiles = new(proc.ExtraFilesPre)
for _, f := range extraFiles {
_, v := cmd.extraFiles.Append()
*v = f
}
argsFd := -1
if wt != nil {
f := proc.NewWriterTo(wt)
argsFd = int(proc.InitFile(f, cmd.extraFiles))
cmd.files = append(cmd.files, f)
cmd.hasArgsFd = true
}
cmd.argF = func(statFd int) []string { return argF(argsFd, statFd) }
return
}
// helperCmd wraps Cmd and implements methods shared across all Helper implementations.
type helperCmd struct {
// ref to parent
r Helper
// returns an array of arguments passed directly
// to the helper process
argF func(statFd int) []string
// whether argsFd is present
hasArgsFd bool
// closes statFd
stat io.Closer
// deferred extraFiles fulfillment
files []proc.File
// passed through to [proc.Fulfill] and [proc.InitFile]
extraFiles *proc.ExtraFilesPre
name string
stdin io.Reader
stdout, stderr io.Writer
env []string
*exec.Cmd
}
func (h *helperCmd) Stdin(r io.Reader) Helper { h.stdin = r; return h.r }
func (h *helperCmd) Stdout(w io.Writer) Helper { h.stdout = w; return h.r }
func (h *helperCmd) Stderr(w io.Writer) Helper { h.stderr = w; return h.r }
func (h *helperCmd) SetEnv(env []string) Helper { h.env = env; return h.r }
// finalise initialises the underlying [exec.Cmd] object.
func (h *helperCmd) finalise(ctx context.Context, stat bool) (args []string) {
h.Cmd = commandContext(ctx, h.name)
h.Cmd.Stdin, h.Cmd.Stdout, h.Cmd.Stderr = h.stdin, h.stdout, h.stderr
h.Cmd.Env = slices.Grow(h.env, 2)
if h.hasArgsFd {
h.Cmd.Env = append(h.Cmd.Env, FortifyHelper+"=1")
} else {
h.Cmd.Env = append(h.Cmd.Env, FortifyHelper+"=0")
}
h.Cmd.Cancel = func() error { return h.Cmd.Process.Signal(syscall.SIGTERM) }
h.Cmd.WaitDelay = WaitDelay
statFd := -1
if stat {
f := proc.NewStat(&h.stat)
statFd = int(proc.InitFile(f, h.extraFiles))
h.files = append(h.files, f)
h.Cmd.Env = append(h.Cmd.Env, FortifyStatus+"=1")
// stat is populated on fulfill
h.Cmd.Cancel = func() error { return h.stat.Close() }
} else {
h.Cmd.Env = append(h.Cmd.Env, FortifyStatus+"=0")
}
return h.argF(statFd)
}
var commandContext = exec.CommandContext

View File

@ -1,9 +1,6 @@
package helper_test package helper_test
import ( import (
"context"
"errors"
"fmt"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@ -26,23 +23,20 @@ var (
argsWt = helper.MustNewCheckedArgs(wantArgs) argsWt = helper.MustNewCheckedArgs(wantArgs)
) )
func argF(argsFd, statFd int) []string { func argF(argsFD, statFD int) []string {
if argsFd == -1 { if argsFD == -1 {
panic("invalid args fd") panic("invalid args fd")
} }
return argFChecked(argsFd, statFd) return argFChecked(argsFD, statFD)
} }
func argFChecked(argsFd, statFd int) (args []string) { func argFChecked(argsFD, statFD int) []string {
args = make([]string, 0, 4) if statFD == -1 {
if argsFd > -1 { return []string{"--args", strconv.Itoa(argsFD)}
args = append(args, "--args", strconv.Itoa(argsFd)) } else {
return []string{"--args", strconv.Itoa(argsFD), "--fd", strconv.Itoa(statFD)}
} }
if statFd > -1 {
args = append(args, "--fd", strconv.Itoa(statFd))
}
return
} }
// this function tests an implementation of the helper.Helper interface // this function tests an implementation of the helper.Helper interface
@ -51,42 +45,66 @@ func testHelper(t *testing.T, createHelper func() helper.Helper) {
t.Run("start helper with status channel and wait", func(t *testing.T) { t.Run("start helper with status channel and wait", func(t *testing.T) {
h := createHelper() h := createHelper()
ready := make(chan error, 1)
cmd := h.Unwrap()
stdout, stderr := new(strings.Builder), new(strings.Builder) stdout, stderr := new(strings.Builder), new(strings.Builder)
h.Stdout(stdout).Stderr(stderr) cmd.Stdout, cmd.Stderr = stdout, stderr
t.Run("wait not yet started helper", func(t *testing.T) { t.Run("wait not yet started helper", func(t *testing.T) {
defer func() { wantErr := "exec: not started"
r := recover() if err := h.Wait(); err != nil && err.Error() != wantErr {
if r == nil { t.Errorf("Wait(%v) error = %v, wantErr %v",
t.Fatalf("Wait did not panic") ready,
}
}()
panic(fmt.Sprintf("unreachable: %v", h.Wait()))
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Log("starting helper stub")
if err := h.Start(ctx, true); err != nil {
t.Errorf("Start: error = %v", err)
cancel()
return
}
t.Log("cancelling context")
cancel()
t.Run("start already started helper", func(t *testing.T) {
wantErr := "exec: already started"
if err := h.Start(ctx, true); err != nil && err.Error() != wantErr {
t.Errorf("Start: error = %v, wantErr %v",
err, wantErr) err, wantErr)
return return
} }
}) })
t.Log("starting helper stub")
if err := h.StartNotify(ready); err != nil {
t.Errorf("StartNotify(%v) error = %v",
ready,
err)
return
}
t.Run("start already started helper", func(t *testing.T) {
wantErr := "exec: already started"
if err := h.StartNotify(ready); err != nil && err.Error() != wantErr {
t.Errorf("StartNotify(%v) error = %v, wantErr %v",
ready,
err, wantErr)
return
}
})
t.Log("waiting on status channel with timeout")
select {
case <-time.NewTimer(5 * time.Second).C:
t.Errorf("never got a ready response")
t.Errorf("stdout:\n%s", stdout.String())
t.Errorf("stderr:\n%s", stderr.String())
if err := cmd.Process.Kill(); err != nil {
panic(err.Error())
}
return
case err := <-ready:
if err != nil {
t.Errorf("StartNotify(%v) latent error = %v",
ready,
err)
}
}
t.Log("closing status pipe")
if err := h.Close(); err != nil {
t.Errorf("Close() error = %v",
err)
}
t.Log("waiting on helper") t.Log("waiting on helper")
if err := h.Wait(); !errors.Is(err, context.Canceled) { if err := h.Wait(); err != nil {
t.Errorf("Wait() err = %v stderr = %s", t.Errorf("Wait() err = %v stderr = %s",
err, stderr) err, stderr)
} }
@ -94,35 +112,51 @@ func testHelper(t *testing.T, createHelper func() helper.Helper) {
t.Run("wait already finalised helper", func(t *testing.T) { t.Run("wait already finalised helper", func(t *testing.T) {
wantErr := "exec: Wait was already called" wantErr := "exec: Wait was already called"
if err := h.Wait(); err != nil && err.Error() != wantErr { if err := h.Wait(); err != nil && err.Error() != wantErr {
t.Errorf("Wait: error = %v, wantErr %v", t.Errorf("Wait(%v) error = %v, wantErr %v",
ready,
err, wantErr) err, wantErr)
return return
} }
}) })
if got := stdout.String(); !strings.HasPrefix(got, wantPayload) { if got := stdout.String(); !strings.HasPrefix(got, wantPayload) {
t.Errorf("Start: stdout = %v, want %v", t.Errorf("StartNotify(%v) stdout = %v, want %v",
ready,
got, wantPayload) got, wantPayload)
} }
}) })
t.Run("start helper and wait", func(t *testing.T) { t.Run("start helper and wait", func(t *testing.T) {
h := createHelper() h := createHelper()
cmd := h.Unwrap()
stdout, stderr := new(strings.Builder), new(strings.Builder) stdout, stderr := new(strings.Builder), new(strings.Builder)
h.Stdout(stdout).Stderr(stderr) cmd.Stdout, cmd.Stderr = stdout, stderr
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.Start(ctx, false); err != nil { if err := h.Start(); err != nil {
t.Errorf("Start() error = %v", t.Errorf("Start() error = %v",
err) err)
return return
} }
t.Run("close helper without status pipe", func(t *testing.T) {
defer func() {
wantPanic := "attempted to close helper with no status pipe"
if r := recover(); r != wantPanic {
t.Errorf("Close() panic = %v, wantPanic %v",
r, wantPanic)
}
}()
if err := h.Close(); err != nil {
t.Errorf("Close() error = %v",
err)
return
}
})
if err := h.Wait(); err != nil { if err := h.Wait(); err != nil {
t.Errorf("Wait() err = %v stdout = %s stderr = %s", t.Errorf("Wait() err = %v stderr = %s",
err, stdout, stderr) err, stderr)
} }
if got := stdout.String(); !strings.HasPrefix(got, wantPayload) { if got := stdout.String(); !strings.HasPrefix(got, wantPayload) {

149
helper/pipe.go Normal file
View File

@ -0,0 +1,149 @@
package helper
import (
"errors"
"io"
"os"
"os/exec"
"git.gensokyo.uk/security/fortify/internal/proc"
)
type pipes struct {
args io.WriterTo
statP [2]*os.File
argsP [2]*os.File
ready chan error
cmd *exec.Cmd
}
func (p *pipes) pipe() error {
if p.statP[0] != nil || p.statP[1] != nil ||
p.argsP[0] != nil || p.argsP[1] != nil {
panic("attempted to pipe twice")
}
if p.args == nil {
panic("attempted to pipe without args")
}
// create pipes
if pr, pw, err := os.Pipe(); err != nil {
return err
} else {
p.argsP[0], p.argsP[1] = pr, pw
}
// create status pipes if ready signal is requested
if p.ready != nil {
if pr, pw, err := os.Pipe(); err != nil {
return err
} else {
p.statP[0], p.statP[1] = pr, pw
}
}
return nil
}
// calls pipe to create pipes and sets them up as ExtraFiles, returning their fd
func (p *pipes) prepareCmd(cmd *exec.Cmd) (argsFd, statFd int, err error) {
argsFd, statFd = -1, -1
if err = p.pipe(); err != nil {
return
}
// save a reference of cmd for future use
p.cmd = cmd
argsFd = int(proc.ExtraFile(cmd, p.argsP[0]))
if p.ready != nil {
statFd = int(proc.ExtraFile(cmd, p.statP[1]))
}
return
}
func (p *pipes) readyWriteArgs() error {
statsP, argsP := p.statP[0], p.argsP[1]
// write arguments and close args pipe
if _, err := p.args.WriteTo(argsP); err != nil {
if err1 := p.cmd.Process.Kill(); err1 != nil {
// should be unreachable
panic(err1.Error())
}
return err
} else {
if err = argsP.Close(); err != nil {
if err1 := p.cmd.Process.Kill(); err1 != nil {
// should be unreachable
panic(err1.Error())
}
return err
}
}
if p.ready != nil {
// monitor stat pipe
go func() {
n, err := statsP.Read(make([]byte, 1))
switch n {
case -1:
if err1 := p.cmd.Process.Kill(); err1 != nil {
// should be unreachable
panic(err1.Error())
}
// ensure error is not nil
if err == nil {
err = ErrStatusFault
}
p.ready <- err
case 0:
// ensure error is not nil
if err == nil {
err = ErrStatusRead
}
p.ready <- err
case 1:
p.ready <- nil
default:
panic("unreachable") // unexpected read count
}
}()
}
return nil
}
func (p *pipes) mustClosePipes() {
if err := p.argsP[0].Close(); err != nil && !errors.Is(err, os.ErrClosed) {
// unreachable
panic(err.Error())
}
if err := p.argsP[1].Close(); err != nil && !errors.Is(err, os.ErrClosed) {
// unreachable
panic(err.Error())
}
if p.ready != nil {
if err := p.statP[0].Close(); err != nil && !errors.Is(err, os.ErrClosed) {
// unreachable
panic(err.Error())
}
if err := p.statP[1].Close(); err != nil && !errors.Is(err, os.ErrClosed) {
// unreachable
panic(err.Error())
}
}
}
func (p *pipes) closeStatus() error {
if p.ready == nil {
panic("attempted to close helper with no status pipe")
}
return p.statP[0].Close()
}

42
helper/pipe_test.go Normal file
View File

@ -0,0 +1,42 @@
package helper
import (
"testing"
)
func Test_pipes_pipe_mustClosePipes(t *testing.T) {
p := new(pipes)
t.Run("pipe without args", func(t *testing.T) {
defer func() {
wantPanic := "attempted to pipe without args"
if r := recover(); r != wantPanic {
t.Errorf("pipe() panic = %v, wantPanic %v",
r, wantPanic)
}
}()
_ = p.pipe()
})
p.args = MustNewCheckedArgs(make([]string, 0))
t.Run("obtain pipes", func(t *testing.T) {
if err := p.pipe(); err != nil {
t.Errorf("pipe() error = %v",
err)
return
}
})
t.Run("pipe twice", func(t *testing.T) {
defer func() {
wantPanic := "attempted to pipe twice"
if r := recover(); r != wantPanic {
t.Errorf("pipe() panic = %v, wantPanic %v",
r, wantPanic)
}
}()
_ = p.pipe()
})
p.mustClosePipes()
}

View File

@ -1,152 +0,0 @@
package proc
import (
"context"
"os"
"os/exec"
"sync/atomic"
"syscall"
"time"
)
var FulfillmentTimeout = 2 * time.Second
// A File is an extra file with deferred initialisation.
type File interface {
// Init initialises File state. Init must not be called more than once.
Init(fd uintptr, v **os.File) uintptr
// Fd returns the fd value set on initialisation.
Fd() uintptr
// ErrCount returns count of error values emitted during fulfillment.
ErrCount() int
// Fulfill is called prior to process creation and must populate its corresponding file address.
// Calls to dispatchErr must match the return value of ErrCount.
// Fulfill must not be called more than once.
Fulfill(ctx context.Context, dispatchErr func(error)) error
}
// ExtraFilesPre is a linked list storing addresses of [os.File].
type ExtraFilesPre struct {
n *ExtraFilesPre
v *os.File
}
// Append grows the list by one entry and returns an address of the address of [os.File] stored in the new entry.
func (f *ExtraFilesPre) Append() (uintptr, **os.File) { return f.append(3) }
// Files returns a slice pointing to a continuous segment of memory containing all addresses stored in f in order.
func (f *ExtraFilesPre) Files() []*os.File { return f.copy(make([]*os.File, 0, f.len())) }
func (f *ExtraFilesPre) append(i uintptr) (uintptr, **os.File) {
if f.n == nil {
f.n = new(ExtraFilesPre)
return i, &f.v
}
return f.n.append(i + 1)
}
func (f *ExtraFilesPre) len() uintptr {
if f == nil {
return 0
}
return f.n.len() + 1
}
func (f *ExtraFilesPre) copy(e []*os.File) []*os.File {
if f == nil {
// the public methods ensure the first call is never nil;
// the last element is unused, slice it off here
return e[:len(e)-1]
}
return f.n.copy(append(e, f.v))
}
// Fulfill calls the [File.Fulfill] method on all files, starts cmd and blocks until all fulfillment completes.
func Fulfill(ctx context.Context, cmd *exec.Cmd, files []File, extraFiles *ExtraFilesPre) (err error) {
var ecs int
for _, o := range files {
ecs += o.ErrCount()
}
ec := make(chan error, ecs)
c, cancel := context.WithTimeout(ctx, FulfillmentTimeout)
defer cancel()
for _, f := range files {
err = f.Fulfill(c, makeDispatchErr(f, ec))
if err != nil {
return
}
}
cmd.ExtraFiles = extraFiles.Files()
if err = cmd.Start(); err != nil {
return
}
for ecs > 0 {
select {
case err = <-ec:
ecs--
if err != nil {
break
}
case <-ctx.Done():
err = syscall.ECANCELED
break
}
}
return
}
// InitFile initialises f as part of the slice extraFiles points to,
// and returns its final fd value.
func InitFile(f File, extraFiles *ExtraFilesPre) (fd uintptr) { return f.Init(extraFiles.Append()) }
// BaseFile implements the Init method of the File interface and provides indirect access to extra file state.
type BaseFile struct {
fd uintptr
v **os.File
}
func (f *BaseFile) Init(fd uintptr, v **os.File) uintptr {
if v == nil || fd < 3 {
panic("invalid extra file initial state")
}
if f.v != nil {
panic("extra file initialised twice")
}
f.fd, f.v = fd, v
return fd
}
func (f *BaseFile) Fd() uintptr {
if f.v == nil {
panic("use of uninitialised extra file")
}
return f.fd
}
func (f *BaseFile) Set(v *os.File) {
*f.v = v // runtime guards against use before init
}
func makeDispatchErr(f File, ec chan<- error) func(error) {
c := new(atomic.Int32)
c.Store(int32(f.ErrCount()))
return func(err error) {
if c.Add(-1) < 0 {
panic("unexpected error dispatches")
}
ec <- err
}
}
func ExtraFile(cmd *exec.Cmd, f *os.File) (fd uintptr) {
return ExtraFileSlice(&cmd.ExtraFiles, f)
}
func ExtraFileSlice(extraFiles *[]*os.File, f *os.File) (fd uintptr) {
// ExtraFiles: If non-nil, entry i becomes file descriptor 3+i.
fd = uintptr(3 + len(*extraFiles))
*extraFiles = append(*extraFiles, f)
return
}

View File

@ -1,100 +0,0 @@
package proc
import (
"context"
"errors"
"io"
"os"
)
// NewWriterTo returns a [File] that receives content from wt on fulfillment.
func NewWriterTo(wt io.WriterTo) File { return &writeToFile{wt: wt} }
// writeToFile exports the read end of a pipe with data written by an [io.WriterTo].
type writeToFile struct {
wt io.WriterTo
BaseFile
}
func (f *writeToFile) ErrCount() int { return 3 }
func (f *writeToFile) Fulfill(ctx context.Context, dispatchErr func(error)) error {
r, w, err := os.Pipe()
if err != nil {
return err
}
f.Set(r)
done := make(chan struct{})
go func() { _, err = f.wt.WriteTo(w); dispatchErr(err); dispatchErr(w.Close()); close(done) }()
go func() {
select {
case <-done:
dispatchErr(nil)
case <-ctx.Done():
dispatchErr(w.Close()) // this aborts WriteTo with file already closed
}
}()
return nil
}
// NewStat returns a [File] implementing the behaviour
// of the receiving end of xdg-dbus-proxy stat fd.
func NewStat(s *io.Closer) File { return &statFile{s: s} }
var (
ErrStatFault = errors.New("generic stat fd fault")
ErrStatRead = errors.New("unexpected stat behaviour")
)
// statFile implements xdg-dbus-proxy stat fd behaviour.
type statFile struct {
s *io.Closer
BaseFile
}
func (f *statFile) ErrCount() int { return 2 }
func (f *statFile) Fulfill(ctx context.Context, dispatchErr func(error)) error {
r, w, err := os.Pipe()
if err != nil {
return err
}
f.Set(w)
done := make(chan struct{})
go func() {
defer close(done)
var n int
n, err = r.Read(make([]byte, 1))
switch n {
case -1:
if err == nil {
err = ErrStatFault
}
dispatchErr(err)
case 0:
if err == nil {
err = ErrStatRead
}
dispatchErr(err)
case 1:
dispatchErr(err)
default:
panic("unreachable")
}
}()
go func() {
select {
case <-done:
dispatchErr(nil)
case <-ctx.Done():
dispatchErr(r.Close()) // this aborts Read with file already closed
}
}()
// this gets closed by the caller
*f.s = r
return nil
}

View File

@ -1,68 +0,0 @@
package seccomp
import (
"context"
"errors"
"syscall"
"git.gensokyo.uk/security/fortify/helper/proc"
)
// New returns an inactive Encoder instance.
func New(opts SyscallOpts) *Encoder { return &Encoder{newExporter(opts)} }
/*
An Encoder writes a BPF program to an output stream.
Methods of Encoder are not safe for concurrent use.
An Encoder must not be copied after first use.
*/
type Encoder struct {
*exporter
}
func (e *Encoder) Read(p []byte) (n int, err error) {
if err = e.prepare(); err != nil {
return
}
return e.r.Read(p)
}
func (e *Encoder) Close() error {
if e.r == nil {
return syscall.EINVAL
}
// this hangs if the cgo thread fails to exit
return errors.Join(e.closeWrite(), <-e.exportErr)
}
// NewFile returns an instance of exporter implementing [proc.File].
func NewFile(opts SyscallOpts) proc.File { return &File{opts: opts} }
// File implements [proc.File] and provides access to the read end of exporter pipe.
type File struct {
opts SyscallOpts
proc.BaseFile
}
func (f *File) ErrCount() int { return 2 }
func (f *File) Fulfill(ctx context.Context, dispatchErr func(error)) error {
e := newExporter(f.opts)
if err := e.prepare(); err != nil {
return err
}
f.Set(e.r)
go func() {
select {
case err := <-e.exportErr:
dispatchErr(nil)
dispatchErr(err)
case <-ctx.Done():
dispatchErr(e.closeWrite())
dispatchErr(<-e.exportErr)
}
}()
return nil
}

View File

@ -1,53 +1,17 @@
package seccomp package seccomp
import ( import (
"io"
"os" "os"
"runtime"
"sync"
) )
type exporter struct { func Export(opts SyscallOpts) (f *os.File, err error) {
opts SyscallOpts if f, err = tmpfile(); err != nil {
r, w *os.File
prepareOnce sync.Once
prepareErr error
closeOnce sync.Once
closeErr error
exportErr <-chan error
}
func (e *exporter) prepare() error {
e.prepareOnce.Do(func() {
if r, w, err := os.Pipe(); err != nil {
e.prepareErr = err
return return
} else {
e.r, e.w = r, w
} }
if err = exportFilter(f.Fd(), opts); err != nil {
ec := make(chan error, 1) return
go func(fd uintptr) { ec <- exportFilter(fd, e.opts); close(ec); _ = e.closeWrite() }(e.w.Fd())
e.exportErr = ec
runtime.SetFinalizer(e, (*exporter).closeWrite)
})
return e.prepareErr
} }
_, err = f.Seek(0, io.SeekStart)
func (e *exporter) closeWrite() error { return
e.closeOnce.Do(func() {
if e.w == nil {
panic("closeWrite called on invalid exporter")
}
e.closeErr = e.w.Close()
// no need for a finalizer anymore
runtime.SetFinalizer(e, nil)
})
return e.closeErr
}
func newExporter(opts SyscallOpts) *exporter {
return &exporter{opts: opts}
} }

View File

@ -1,139 +0,0 @@
package seccomp_test
import (
"crypto/sha512"
"errors"
"io"
"slices"
"syscall"
"testing"
"git.gensokyo.uk/security/fortify/helper/seccomp"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
func TestExport(t *testing.T) {
testCases := []struct {
name string
opts seccomp.SyscallOpts
want []byte
wantErr bool
}{
{"compat", 0, []byte{
0x95, 0xec, 0x69, 0xd0, 0x17, 0x73, 0x3e, 0x07,
0x21, 0x60, 0xe0, 0xda, 0x80, 0xfd, 0xeb, 0xec,
0xdf, 0x27, 0xae, 0x81, 0x66, 0xf5, 0xe2, 0xa7,
0x31, 0x27, 0x0c, 0x98, 0xea, 0x2d, 0x29, 0x46,
0xcb, 0x52, 0x31, 0x02, 0x90, 0x63, 0x66, 0x8a,
0xf2, 0x15, 0x87, 0x91, 0x55, 0xda, 0x21, 0xac,
0xa7, 0x9b, 0x07, 0x0e, 0x04, 0xc0, 0xee, 0x9a,
0xcd, 0xf5, 0x8f, 0x55, 0xcf, 0xa8, 0x15, 0xa5,
}, false},
{"base", seccomp.FlagExt, []byte{
0xdc, 0x7f, 0x2e, 0x1c, 0x5e, 0x82, 0x9b, 0x79,
0xeb, 0xb7, 0xef, 0xc7, 0x59, 0x15, 0x0f, 0x54,
0xa8, 0x3a, 0x75, 0xc8, 0xdf, 0x6f, 0xee, 0x4d,
0xce, 0x5d, 0xad, 0xc4, 0x73, 0x6c, 0x58, 0x5d,
0x4d, 0xee, 0xbf, 0xeb, 0x3c, 0x79, 0x69, 0xaf,
0x3a, 0x07, 0x7e, 0x90, 0xb7, 0x7b, 0xb4, 0x74,
0x1d, 0xb0, 0x5d, 0x90, 0x99, 0x7c, 0x86, 0x59,
0xb9, 0x58, 0x91, 0x20, 0x6a, 0xc9, 0x95, 0x2d,
}, false},
{"everything", seccomp.FlagExt |
seccomp.FlagDenyNS | seccomp.FlagDenyTTY | seccomp.FlagDenyDevel |
seccomp.FlagMultiarch | seccomp.FlagLinux32 | seccomp.FlagCan |
seccomp.FlagBluetooth, []byte{
0xe9, 0x9d, 0xd3, 0x45, 0xe1, 0x95, 0x41, 0x34,
0x73, 0xd3, 0xcb, 0xee, 0x07, 0xb4, 0xed, 0x57,
0xb9, 0x08, 0xbf, 0xa8, 0x9e, 0xa2, 0x07, 0x2f,
0xe9, 0x34, 0x82, 0x84, 0x7f, 0x50, 0xb5, 0xb7,
0x58, 0xda, 0x17, 0xe7, 0x4c, 0xa2, 0xbb, 0xc0,
0x08, 0x13, 0xde, 0x49, 0xa2, 0xb9, 0xbf, 0x83,
0x4c, 0x02, 0x4e, 0xd4, 0x88, 0x50, 0xbe, 0x69,
0xb6, 0x8a, 0x9a, 0x4c, 0x5f, 0x53, 0xa9, 0xdb,
}, false},
{"strict", seccomp.FlagExt |
seccomp.FlagDenyNS | seccomp.FlagDenyTTY | seccomp.FlagDenyDevel, []byte{
0xe8, 0x80, 0x29, 0x8d, 0xf2, 0xbd, 0x67, 0x51,
0xd0, 0x04, 0x0f, 0xc2, 0x1b, 0xc0, 0xed, 0x4c,
0x00, 0xf9, 0x5d, 0xc0, 0xd7, 0xba, 0x50, 0x6c,
0x24, 0x4d, 0x8b, 0x8c, 0xf6, 0x86, 0x6d, 0xba,
0x8e, 0xf4, 0xa3, 0x32, 0x96, 0xf2, 0x87, 0xb6,
0x6c, 0xcc, 0xc1, 0xd7, 0x8e, 0x97, 0x02, 0x65,
0x97, 0xf8, 0x4c, 0xc7, 0xde, 0xc1, 0x57, 0x3e,
0x14, 0x89, 0x60, 0xfb, 0xd3, 0x5c, 0xd7, 0x35,
}, false},
{"strict compat", 0 |
seccomp.FlagDenyNS | seccomp.FlagDenyTTY | seccomp.FlagDenyDevel, []byte{
0x39, 0x87, 0x1b, 0x93, 0xff, 0xaf, 0xc8, 0xb9,
0x79, 0xfc, 0xed, 0xc0, 0xb0, 0xc3, 0x7b, 0x9e,
0x03, 0x92, 0x2f, 0x5b, 0x02, 0x74, 0x8d, 0xc5,
0xc3, 0xc1, 0x7c, 0x92, 0x52, 0x7f, 0x6e, 0x02,
0x2e, 0xde, 0x1f, 0x48, 0xbf, 0xf5, 0x92, 0x46,
0xea, 0x45, 0x2c, 0x0d, 0x1d, 0xe5, 0x48, 0x27,
0x80, 0x8b, 0x1a, 0x6f, 0x84, 0xf3, 0x2b, 0xbd,
0xe1, 0xaa, 0x02, 0xae, 0x30, 0xee, 0xdc, 0xfa,
}, false},
}
buf := make([]byte, 8)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
seccomp.CPrintln = fmsg.Println
t.Cleanup(func() { seccomp.CPrintln = nil })
e := seccomp.New(tc.opts)
digest := sha512.New()
if _, err := io.CopyBuffer(digest, e, buf); (err != nil) != tc.wantErr {
t.Errorf("Exporter: error = %v, wantErr %v", err, tc.wantErr)
return
}
if err := e.Close(); err != nil {
t.Errorf("Close: error = %v", err)
return
}
if got := digest.Sum(nil); slices.Compare(got, tc.want) != 0 {
t.Fatalf("Export() hash = %x, want %x",
got, tc.want)
return
}
})
}
t.Run("close without use", func(t *testing.T) {
e := seccomp.New(0)
if err := e.Close(); !errors.Is(err, syscall.EINVAL) {
t.Errorf("Close: error = %v", err)
return
}
})
t.Run("close partial read", func(t *testing.T) {
e := seccomp.New(0)
if _, err := e.Read(make([]byte, 0)); err != nil {
t.Errorf("Read: error = %v", err)
return
}
if err := e.Close(); err == nil || err.Error() != "seccomp_export_bpf failed: operation canceled" {
t.Errorf("Close: error = %v", err)
return
}
})
}
func BenchmarkExport(b *testing.B) {
buf := make([]byte, 8)
for i := 0; i < b.N; i++ {
e := seccomp.New(seccomp.FlagExt |
seccomp.FlagDenyNS | seccomp.FlagDenyTTY | seccomp.FlagDenyDevel |
seccomp.FlagMultiarch | seccomp.FlagLinux32 | seccomp.FlagCan |
seccomp.FlagBluetooth)
if _, err := io.CopyBuffer(io.Discard, e, buf); err != nil {
b.Fatalf("cannot export: %v", err)
}
if err := e.Close(); err != nil {
b.Fatalf("cannot close exporter: %v", err)
}
}
}

View File

@ -48,6 +48,14 @@ struct f_syscall_act {
} \ } \
} while (0) } while (0)
int f_tmpfile_fd() {
FILE *f = tmpfile();
if (f == NULL)
return -1;
return fileno(f);
}
int32_t f_export_bpf(int fd, uint32_t arch, uint32_t multiarch, f_syscall_opts opts) { int32_t f_export_bpf(int fd, uint32_t arch, uint32_t multiarch, f_syscall_opts opts) {
int32_t res = 0; // refer to resErr for meaning int32_t res = 0; // refer to resErr for meaning
int allow_multiarch = opts & F_MULTIARCH; int allow_multiarch = opts & F_MULTIARCH;

View File

@ -20,4 +20,5 @@ typedef enum {
} f_syscall_opts; } f_syscall_opts;
extern void F_println(char *v); extern void F_println(char *v);
int f_tmpfile_fd();
int32_t f_export_bpf(int fd, uint32_t arch, uint32_t multiarch, f_syscall_opts opts); int32_t f_export_bpf(int fd, uint32_t arch, uint32_t multiarch, f_syscall_opts opts);

View File

@ -9,6 +9,7 @@ import "C"
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"runtime" "runtime"
) )
@ -28,24 +29,24 @@ type SyscallOpts = C.f_syscall_opts
const ( const (
flagVerbose SyscallOpts = C.F_VERBOSE flagVerbose SyscallOpts = C.F_VERBOSE
// FlagExt are project-specific extensions.
FlagExt SyscallOpts = C.F_EXT FlagExt SyscallOpts = C.F_EXT
// FlagDenyNS denies namespace setup syscalls.
FlagDenyNS SyscallOpts = C.F_DENY_NS FlagDenyNS SyscallOpts = C.F_DENY_NS
// FlagDenyTTY denies faking input.
FlagDenyTTY SyscallOpts = C.F_DENY_TTY FlagDenyTTY SyscallOpts = C.F_DENY_TTY
// FlagDenyDevel denies development-related syscalls.
FlagDenyDevel SyscallOpts = C.F_DENY_DEVEL FlagDenyDevel SyscallOpts = C.F_DENY_DEVEL
// FlagMultiarch allows multiarch/emulation.
FlagMultiarch SyscallOpts = C.F_MULTIARCH FlagMultiarch SyscallOpts = C.F_MULTIARCH
// FlagLinux32 sets PER_LINUX32.
FlagLinux32 SyscallOpts = C.F_LINUX32 FlagLinux32 SyscallOpts = C.F_LINUX32
// FlagCan allows AF_CAN.
FlagCan SyscallOpts = C.F_CAN FlagCan SyscallOpts = C.F_CAN
// FlagBluetooth allows AF_BLUETOOTH.
FlagBluetooth SyscallOpts = C.F_BLUETOOTH FlagBluetooth SyscallOpts = C.F_BLUETOOTH
) )
func tmpfile() (*os.File, error) {
fd, err := C.f_tmpfile_fd()
if err != nil {
return nil, err
}
return os.NewFile(uintptr(fd), "tmpfile"), err
}
func exportFilter(fd uintptr, opts SyscallOpts) error { func exportFilter(fd uintptr, opts SyscallOpts) error {
var ( var (
arch C.uint32_t = 0 arch C.uint32_t = 0

View File

@ -1,9 +1,7 @@
package helper package helper
import ( import (
"context"
"flag" "flag"
"fmt"
"io" "io"
"os" "os"
"os/exec" "os/exec"
@ -13,7 +11,6 @@ import (
"testing" "testing"
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/helper/proc"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
@ -21,23 +18,20 @@ import (
// it is part of the implementation of the helper stub. // it is part of the implementation of the helper stub.
func InternalChildStub() { func InternalChildStub() {
// this test mocks the helper process // this test mocks the helper process
var ap, sp string if os.Getenv(FortifyHelper) != "1" ||
if v, ok := os.LookupEnv(FortifyHelper); !ok { os.Getenv(FortifyStatus) == "-1" { // this indicates the stub is being invoked as a bwrap child without pipes
return return
} else {
ap = v
}
if v, ok := os.LookupEnv(FortifyStatus); !ok {
panic(FortifyStatus)
} else {
sp = v
} }
argsFD := flag.Int("args", -1, "")
statFD := flag.Int("fd", -1, "")
_ = flag.CommandLine.Parse(os.Args[4:])
switch os.Args[3] { switch os.Args[3] {
case "bwrap": case "bwrap":
bwrapStub() bwrapStub(argsFD, statFD)
default: default:
genericStub(flagRestoreFiles(4, ap, sp)) genericStub(argsFD, statFD)
} }
fmsg.Exit(0) fmsg.Exit(0)
@ -46,65 +40,57 @@ func InternalChildStub() {
// InternalReplaceExecCommand is an internal function but exported because it is cross-package; // InternalReplaceExecCommand is an internal function but exported because it is cross-package;
// it is part of the implementation of the helper stub. // it is part of the implementation of the helper stub.
func InternalReplaceExecCommand(t *testing.T) { func InternalReplaceExecCommand(t *testing.T) {
t.Cleanup(func() { commandContext = exec.CommandContext }) t.Cleanup(func() {
execCommand = exec.Command
})
// replace execCommand to have the resulting *exec.Cmd launch TestHelperChildStub // replace execCommand to have the resulting *exec.Cmd launch TestHelperChildStub
commandContext = func(ctx context.Context, name string, arg ...string) *exec.Cmd { execCommand = func(name string, arg ...string) *exec.Cmd {
// pass through nonexistent path // pass through nonexistent path
if name == "/nonexistent" && len(arg) == 0 { if name == "/nonexistent" && len(arg) == 0 {
return exec.CommandContext(ctx, name) return exec.Command(name)
} }
return exec.CommandContext(ctx, os.Args[0], append([]string{"-test.run=TestHelperChildStub", "--", name}, arg...)...) return exec.Command(os.Args[0], append([]string{"-test.run=TestHelperChildStub", "--", name}, arg...)...)
} }
} }
func newFile(fd int, name, p string) *os.File { func genericStub(argsFD, statFD *int) {
present := false // simulate args pipe behaviour
switch p { func() {
case "0": if *argsFD == -1 {
case "1": panic("attempted to start helper without passing args pipe fd")
present = true
default:
panic(fmt.Sprintf("%s fd has unexpected presence value %q", name, p))
} }
f := os.NewFile(uintptr(fd), name) f := os.NewFile(uintptr(*argsFD), "|0")
if !present && f != nil { if f == nil {
panic(fmt.Sprintf("%s fd set but not present", name)) panic("attempted to start helper without args pipe")
}
if present && f == nil {
panic(fmt.Sprintf("%s fd preset but unset", name))
} }
return f if _, err := io.Copy(os.Stdout, f); err != nil {
}
func flagRestoreFiles(offset int, ap, sp string) (argsFile, statFile *os.File) {
argsFd := flag.Int("args", -1, "")
statFd := flag.Int("fd", -1, "")
_ = flag.CommandLine.Parse(os.Args[offset:])
argsFile = newFile(*argsFd, "args", ap)
statFile = newFile(*statFd, "stat", sp)
return
}
func genericStub(argsFile, statFile *os.File) {
if argsFile != nil {
// this output is checked by parent
if _, err := io.Copy(os.Stdout, argsFile); err != nil {
panic("cannot read args: " + err.Error()) panic("cannot read args: " + err.Error())
} }
} }()
var wait chan struct{}
// simulate status pipe behaviour // simulate status pipe behaviour
if statFile != nil { if os.Getenv(FortifyStatus) == "1" {
if _, err := statFile.Write([]byte{'x'}); err != nil { if *statFD == -1 {
panic("attempted to start helper with status reporting without passing status pipe fd")
}
wait = make(chan struct{})
go func() {
f := os.NewFile(uintptr(*statFD), "|1")
if f == nil {
panic("attempted to start with status reporting without status pipe")
}
if _, err := f.Write([]byte{'x'}); err != nil {
panic("cannot write to status pipe: " + err.Error()) panic("cannot write to status pipe: " + err.Error())
} }
done := make(chan struct{})
go func() {
// wait for status pipe close // wait for status pipe close
var epoll int var epoll int
if fd, err := syscall.EpollCreate1(0); err != nil { if fd, err := syscall.EpollCreate1(0); err != nil {
@ -117,7 +103,7 @@ func genericStub(argsFile, statFile *os.File) {
}() }()
epoll = fd epoll = fd
} }
if err := syscall.EpollCtl(epoll, syscall.EPOLL_CTL_ADD, int(statFile.Fd()), &syscall.EpollEvent{}); err != nil { if err := syscall.EpollCtl(epoll, syscall.EPOLL_CTL_ADD, int(f.Fd()), &syscall.EpollEvent{}); err != nil {
panic("cannot add status pipe to epoll: " + err.Error()) panic("cannot add status pipe to epoll: " + err.Error())
} }
events := make([]syscall.EpollEvent, 1) events := make([]syscall.EpollEvent, 1)
@ -128,36 +114,50 @@ func genericStub(argsFile, statFile *os.File) {
panic(strconv.Itoa(int(events[0].Events))) panic(strconv.Itoa(int(events[0].Events)))
} }
close(done) close(wait)
}() }()
<-done }
if wait != nil {
<-wait
} }
} }
func bwrapStub() { func bwrapStub(argsFD, statFD *int) {
// the bwrap launcher does not launch with a typical sync fd // the bwrap launcher does not ever launch with sync fd
argsFile, _ := flagRestoreFiles(4, "1", "0") if *statFD != -1 {
panic("attempted to launch bwrap with status monitoring")
}
// test args pipe behaviour // test args pipe behaviour
func() { func() {
if *argsFD == -1 {
panic("attempted to start bwrap without passing args pipe fd")
}
f := os.NewFile(uintptr(*argsFD), "|0")
if f == nil {
panic("attempted to start helper without args pipe")
}
got, want := new(strings.Builder), new(strings.Builder) got, want := new(strings.Builder), new(strings.Builder)
if _, err := io.Copy(got, argsFile); err != nil {
panic("cannot read bwrap args: " + err.Error()) if _, err := io.Copy(got, f); err != nil {
panic("cannot read args: " + err.Error())
} }
// hardcoded bwrap configuration used by test // hardcoded bwrap configuration used by test
sc := &bwrap.Config{ if _, err := MustNewCheckedArgs((&bwrap.Config{
Unshare: nil,
Net: true, Net: true,
UserNS: false,
Hostname: "localhost", Hostname: "localhost",
Chdir: "/nonexistent", Chdir: "/nonexistent",
Clearenv: true, Clearenv: true,
NewSession: true, NewSession: true,
DieWithParent: true, DieWithParent: true,
AsInit: true, AsInit: true,
} }).Args()).WriteTo(want); err != nil {
args := sc.Args()
sc.FDArgs(nil, &args, new(proc.ExtraFilesPre), new([]proc.File))
if _, err := MustNewCheckedArgs(args).WriteTo(want); err != nil {
panic("cannot read want: " + err.Error()) panic("cannot read want: " + err.Error())
} }

View File

@ -6,7 +6,7 @@ import (
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/linux"
"git.gensokyo.uk/security/fortify/internal/priv/shim" "git.gensokyo.uk/security/fortify/internal/proc/priv/shim"
) )
type App interface { type App interface {

View File

@ -34,8 +34,8 @@ var posixUsername = regexp.MustCompilePOSIX("^[a-z_]([A-Za-z0-9_-]{0,31}|[A-Za-z
type appSeal struct { type appSeal struct {
// app unique ID string representation // app unique ID string representation
id string id string
// dump dbus proxy message buffer // dbus proxy message buffer retriever
dbusMsg func() dbusMsg func(f func(msgbuf []string))
// freedesktop application ID // freedesktop application ID
fid string fid string

View File

@ -11,7 +11,7 @@ import (
"git.gensokyo.uk/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/priv/shim" "git.gensokyo.uk/security/fortify/internal/proc/priv/shim"
"git.gensokyo.uk/security/fortify/internal/state" "git.gensokyo.uk/security/fortify/internal/state"
"git.gensokyo.uk/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
@ -46,7 +46,7 @@ func (a *app) Run(ctx context.Context, rs *RunState) error {
} }
// startup will go ahead, commit system setup // startup will go ahead, commit system setup
if err := a.seal.sys.Commit(ctx); err != nil { if err := a.seal.sys.Commit(); err != nil {
return err return err
} }
a.seal.sys.needRevert = true a.seal.sys.needRevert = true
@ -140,7 +140,11 @@ func (a *app) Run(ctx context.Context, rs *RunState) error {
// print queued up dbus messages // print queued up dbus messages
if a.seal.dbusMsg != nil { if a.seal.dbusMsg != nil {
a.seal.dbusMsg() a.seal.dbusMsg(func(msgbuf []string) {
for _, msg := range msgbuf {
fmsg.Println(msg)
}
})
} }
// update store and revert app setup transaction // update store and revert app setup transaction

17
internal/proc/files.go Normal file
View File

@ -0,0 +1,17 @@
package proc
import (
"os"
"os/exec"
)
func ExtraFile(cmd *exec.Cmd, f *os.File) (fd uintptr) {
return ExtraFileSlice(&cmd.ExtraFiles, f)
}
func ExtraFileSlice(extraFiles *[]*os.File, f *os.File) (fd uintptr) {
// ExtraFiles: If non-nil, entry i becomes file descriptor 3+i.
fd = uintptr(3 + len(*extraFiles))
*extraFiles = append(*extraFiles, f)
return
}

View File

@ -8,9 +8,9 @@ import (
"syscall" "syscall"
"time" "time"
"git.gensokyo.uk/security/fortify/helper/proc"
"git.gensokyo.uk/security/fortify/internal" "git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/proc"
) )
const ( const (

View File

@ -1,22 +1,18 @@
package shim package shim
import ( import (
"context"
"errors" "errors"
"os" "os"
"os/exec"
"os/signal"
"path" "path"
"strconv" "strconv"
"syscall"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/helper/proc"
"git.gensokyo.uk/security/fortify/helper/seccomp" "git.gensokyo.uk/security/fortify/helper/seccomp"
"git.gensokyo.uk/security/fortify/internal" "git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
init0 "git.gensokyo.uk/security/fortify/internal/priv/init" "git.gensokyo.uk/security/fortify/internal/proc"
init0 "git.gensokyo.uk/security/fortify/internal/proc/priv/init"
) )
// everything beyond this point runs as unconstrained target user // everything beyond this point runs as unconstrained target user
@ -142,22 +138,19 @@ func Main() {
); err != nil { ); err != nil {
fmsg.Fatalf("malformed sandbox config: %v", err) fmsg.Fatalf("malformed sandbox config: %v", err)
} else { } else {
b.Stdin(os.Stdin).Stdout(os.Stdout).Stderr(os.Stderr) cmd := b.Unwrap()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
defer stop() // unreachable
// run and pass through exit code // run and pass through exit code
if err = b.Start(ctx, false); err != nil { if err = b.Start(); err != nil {
fmsg.Fatalf("cannot start target process: %v", err) fmsg.Fatalf("cannot start target process: %v", err)
} else if err = b.Wait(); err != nil { } else if err = b.Wait(); err != nil {
var exitError *exec.ExitError fmsg.VPrintln("wait:", err)
if !errors.As(err, &exitError) { }
fmsg.Println("wait:", err) if b.Unwrap().ProcessState != nil {
fmsg.Exit(b.Unwrap().ProcessState.ExitCode())
} else {
fmsg.Exit(127) fmsg.Exit(127)
panic("unreachable")
}
fmsg.Exit(exitError.ExitCode())
panic("unreachable")
} }
} }
} }

View File

@ -10,9 +10,9 @@ import (
"strings" "strings"
"time" "time"
"git.gensokyo.uk/security/fortify/helper/proc"
"git.gensokyo.uk/security/fortify/internal" "git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/proc"
) )
// used by the parent process // used by the parent process

View File

@ -3,6 +3,7 @@ package system
import ( import (
"bytes" "bytes"
"errors" "errors"
"os"
"strings" "strings"
"sync" "sync"
@ -22,9 +23,12 @@ func (sys *I) MustProxyDBus(sessionPath string, session *dbus.Config, systemPath
} }
} }
func (sys *I) ProxyDBus(session, system *dbus.Config, sessionPath, systemPath string) (func(), error) { func (sys *I) ProxyDBus(session, system *dbus.Config, sessionPath, systemPath string) (func(f func(msgbuf []string)), error) {
d := new(DBus) d := new(DBus)
// used by waiting goroutine to notify process exit
d.done = make(chan struct{})
// session bus is mandatory // session bus is mandatory
if session == nil { if session == nil {
return nil, fmsg.WrapError(ErrDBusConfig, return nil, fmsg.WrapError(ErrDBusConfig,
@ -61,7 +65,7 @@ func (sys *I) ProxyDBus(session, system *dbus.Config, sessionPath, systemPath st
// seal dbus proxy // seal dbus proxy
d.out = &scanToFmsg{msg: new(strings.Builder)} d.out = &scanToFmsg{msg: new(strings.Builder)}
return d.out.Dump, fmsg.WrapErrorSuffix(d.proxy.Seal(session, system), return d.out.F, fmsg.WrapErrorSuffix(d.proxy.Seal(session, system),
"cannot seal message bus proxy:") "cannot seal message bus proxy:")
} }
@ -71,34 +75,84 @@ type DBus struct {
out *scanToFmsg out *scanToFmsg
// whether system bus proxy is enabled // whether system bus proxy is enabled
system bool system bool
// notification from goroutine waiting for dbus.Proxy
done chan struct{}
} }
func (d *DBus) Type() Enablement { func (d *DBus) Type() Enablement {
return Process return Process
} }
func (d *DBus) apply(sys *I) error { func (d *DBus) apply(_ *I) error {
fmsg.VPrintf("session bus proxy on %q for upstream %q", d.proxy.Session()[1], d.proxy.Session()[0]) fmsg.VPrintf("session bus proxy on %q for upstream %q", d.proxy.Session()[1], d.proxy.Session()[0])
if d.system { if d.system {
fmsg.VPrintf("system bus proxy on %q for upstream %q", d.proxy.System()[1], d.proxy.System()[0]) fmsg.VPrintf("system bus proxy on %q for upstream %q", d.proxy.System()[1], d.proxy.System()[0])
} }
// this starts the process and blocks until ready // ready channel passed to dbus package
if err := d.proxy.Start(sys.ctx, d.out, true); err != nil { ready := make(chan error, 1)
d.out.Dump()
// background dbus proxy start
if err := d.proxy.Start(ready, d.out, true, true); err != nil {
return fmsg.WrapErrorSuffix(err, return fmsg.WrapErrorSuffix(err,
"cannot start message bus proxy:") "cannot start message bus proxy:")
} }
fmsg.VPrintln("starting message bus proxy:", d.proxy) fmsg.VPrintln("starting message bus proxy:", d.proxy)
if fmsg.Verbose() { // save the extra bwrap arg build when verbose logging is off
fmsg.VPrintln("message bus proxy bwrap args:", d.proxy.BwrapStatic())
}
// background wait for proxy instance and notify completion
go func() {
if err := d.proxy.Wait(); err != nil {
fmsg.Println("message bus proxy exited with error:", err)
go func() { ready <- err }()
} else {
fmsg.VPrintln("message bus proxy exit")
}
// ensure socket removal so ephemeral directory is empty at revert
if err := os.Remove(d.proxy.Session()[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
fmsg.Println("cannot remove dangling session bus socket:", err)
}
if d.system {
if err := os.Remove(d.proxy.System()[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
fmsg.Println("cannot remove dangling system bus socket:", err)
}
}
// notify proxy completion
close(d.done)
}()
// ready is not nil if the proxy process faulted
if err := <-ready; err != nil {
// note that err here is either an I/O error or a predetermined unexpected behaviour error
return fmsg.WrapErrorSuffix(err,
"message bus proxy fault after start:")
}
fmsg.VPrintln("message bus proxy ready")
return nil return nil
} }
func (d *DBus) revert(_ *I, _ *Criteria) error { func (d *DBus) revert(_ *I, _ *Criteria) error {
// criteria ignored here since dbus is always process-scoped // criteria ignored here since dbus is always process-scoped
fmsg.VPrintln("terminating message bus proxy") fmsg.VPrintln("terminating message bus proxy")
d.proxy.Close()
defer fmsg.VPrintln("message bus proxy exit") if err := d.proxy.Close(); err != nil {
return fmsg.WrapErrorSuffix(d.proxy.Wait(), "message bus proxy error:") if errors.Is(err, os.ErrClosed) {
return fmsg.WrapError(err,
"message bus proxy already closed")
} else {
return fmsg.WrapErrorSuffix(err,
"cannot stop message bus proxy:")
}
}
// block until proxy wait returns
<-d.done
return nil
} }
func (d *DBus) Is(o Op) bool { func (d *DBus) Is(o Op) bool {
@ -141,10 +195,8 @@ func (s *scanToFmsg) write(p []byte, a int) (int, error) {
} }
} }
func (s *scanToFmsg) Dump() { func (s *scanToFmsg) F(f func(msgbuf []string)) {
s.mu.RLock() s.mu.RLock()
for _, msg := range s.msgbuf { f(s.msgbuf)
fmsg.Println(msg)
}
s.mu.RUnlock() s.mu.RUnlock()
} }

View File

@ -1,7 +1,6 @@
package system package system
import ( import (
"context"
"errors" "errors"
"os" "os"
"sync" "sync"
@ -58,13 +57,9 @@ func TypeString(e Enablement) string {
type I struct { type I struct {
uid int uid int
ops []Op ops []Op
ctx context.Context
// sync fd passed to bwrap
sp *os.File sp *os.File
// whether sys has been reverted state [2]bool
state bool
lock sync.Mutex lock sync.Mutex
} }
@ -90,14 +85,14 @@ func (sys *I) Equal(v *I) bool {
return true return true
} }
func (sys *I) Commit(ctx context.Context) error { func (sys *I) Commit() error {
sys.lock.Lock() sys.lock.Lock()
defer sys.lock.Unlock() defer sys.lock.Unlock()
if sys.ctx != nil { if sys.state[0] {
panic("sys instance committed twice") panic("sys instance committed twice")
} }
sys.ctx = ctx sys.state[0] = true
sp := New(sys.uid) sp := New(sys.uid)
sp.ops = make([]Op, 0, len(sys.ops)) // prevent copies during commits sp.ops = make([]Op, 0, len(sys.ops)) // prevent copies during commits
@ -130,10 +125,10 @@ func (sys *I) Revert(ec *Criteria) error {
sys.lock.Lock() sys.lock.Lock()
defer sys.lock.Unlock() defer sys.lock.Unlock()
if sys.state { if sys.state[1] {
panic("sys instance reverted twice") panic("sys instance reverted twice")
} }
sys.state = true sys.state[1] = true
// collect errors // collect errors
errs := make([]error, len(sys.ops)) errs := make([]error, len(sys.ops))

View File

@ -1,19 +1,20 @@
package ldd package ldd
import ( import (
"context" "fmt"
"os" "os"
"os/exec"
"strings" "strings"
"time"
"git.gensokyo.uk/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.gensokyo.uk/security/fortify/helper/bwrap"
) )
const lddTimeout = 2 * time.Second func Exec(p string) ([]*Entry, error) {
var (
func Exec(ctx context.Context, p string) ([]*Entry, error) { h helper.Helper
var h helper.Helper cmd *exec.Cmd
)
if b, err := helper.NewBwrap( if b, err := helper.NewBwrap(
(&bwrap.Config{ (&bwrap.Config{
@ -28,20 +29,17 @@ func Exec(ctx context.Context, p string) ([]*Entry, error) {
); err != nil { ); err != nil {
return nil, err return nil, err
} else { } else {
cmd = b.Unwrap()
h = b h = b
} }
stdout := new(strings.Builder) cmd.Stdout, cmd.Stderr = new(strings.Builder), os.Stderr
h.Stdout(stdout).Stderr(os.Stderr) if err := h.Start(); err != nil {
c, cancel := context.WithTimeout(ctx, lddTimeout)
defer cancel()
if err := h.Start(c, false); err != nil {
return nil, err return nil, err
} }
if err := h.Wait(); err != nil { if err := h.Wait(); err != nil {
return nil, err return nil, err
} }
return Parse(stdout) return Parse(cmd.Stdout.(fmt.Stringer))
} }

18
main.go
View File

@ -21,8 +21,8 @@ import (
"git.gensokyo.uk/security/fortify/internal/app" "git.gensokyo.uk/security/fortify/internal/app"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/linux" "git.gensokyo.uk/security/fortify/internal/linux"
init0 "git.gensokyo.uk/security/fortify/internal/priv/init" init0 "git.gensokyo.uk/security/fortify/internal/proc/priv/init"
"git.gensokyo.uk/security/fortify/internal/priv/shim" "git.gensokyo.uk/security/fortify/internal/proc/priv/shim"
"git.gensokyo.uk/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
@ -307,14 +307,22 @@ func main() {
func runApp(config *fst.Config) { func runApp(config *fst.Config) {
rs := new(app.RunState) rs := new(app.RunState)
ctx, stop := signal.NotifyContext(context.Background(), ctx, cancel := context.WithCancel(context.Background())
syscall.SIGINT, syscall.SIGTERM)
defer stop() // unreachable
if fmsg.Verbose() { if fmsg.Verbose() {
seccomp.CPrintln = fmsg.Println seccomp.CPrintln = fmsg.Println
} }
// handle signals for graceful shutdown
sig := make(chan os.Signal, 2)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
go func() {
v := <-sig
fmsg.Printf("got %s after program start", v)
cancel()
signal.Ignore(syscall.SIGINT, syscall.SIGTERM)
}()
if a, err := app.New(sys); err != nil { if a, err := app.New(sys); err != nil {
fmsg.Fatalf("cannot create app: %s\n", err) fmsg.Fatalf("cannot create app: %s\n", err)
} else if err = a.Seal(config); err != nil { } else if err = a.Seal(config); err != nil {

View File

@ -36,7 +36,7 @@ package
*Default:* *Default:*
` <derivation fortify-0.2.13> ` ` <derivation fortify-0.2.12> `

View File

@ -16,7 +16,7 @@
buildGoModule rec { buildGoModule rec {
pname = "fortify"; pname = "fortify";
version = "0.2.13"; version = "0.2.12";
src = builtins.path { src = builtins.path {
name = "fortify-src"; name = "fortify-src";

View File

@ -147,18 +147,6 @@ nixosTest {
pulse = false; pulse = false;
}; };
} }
{
name = "strace-failure";
verbose = true;
share = pkgs.strace;
command = "strace true";
capability = {
wayland = false;
x11 = false;
dbus = false;
pulse = false;
};
}
]; ];
}; };
@ -260,6 +248,9 @@ nixosTest {
# Deny unmapped uid: # Deny unmapped uid:
print(machine.fail("sudo -u untrusted -i ${self.packages.${system}.fortify}/bin/fortify -v run")) print(machine.fail("sudo -u untrusted -i ${self.packages.${system}.fortify}/bin/fortify -v run"))
# Create fortify uid 0 state directory:
machine.succeed("install -dm 0755 -o u0_a0 -g users /var/lib/fortify/u0")
# Start fortify permissive defaults outside Wayland session: # Start fortify permissive defaults outside Wayland session:
print(machine.succeed("sudo -u alice -i fortify -v run -a 0 touch /tmp/success-bare")) print(machine.succeed("sudo -u alice -i fortify -v run -a 0 touch /tmp/success-bare"))
machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-bare") machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-bare")
@ -322,9 +313,6 @@ nixosTest {
machine.send_chars("exit\n") machine.send_chars("exit\n")
machine.wait_until_fails("pgrep alacritty") machine.wait_until_fails("pgrep alacritty")
# Test syscall filter:
print(machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 strace-failure"))
# Exit Sway and verify process exit status 0: # Exit Sway and verify process exit status 0:
swaymsg("exit", succeed=False) swaymsg("exit", succeed=False)
machine.wait_until_fails("pgrep -x sway") machine.wait_until_fails("pgrep -x sway")