Compare commits

..

23 Commits

Author SHA1 Message Date
7106b00968
release: 0.2.11
All checks were successful
Build / Create distribution (push) Successful in 3m51s
Release / Create release (push) Successful in 4m12s
Test / Run NixOS test (push) Successful in 6m17s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-23 20:49:49 +09:00
96d5d8a396
nix: apply shared home config to reserved aid
All checks were successful
Build / Create distribution (push) Successful in 2m16s
Test / Run NixOS test (push) Successful in 5m43s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-23 20:48:04 +09:00
8a00a83c71
nix: expose syscall filter policy
All checks were successful
Build / Create distribution (push) Successful in 1m31s
Test / Run NixOS test (push) Successful in 1m52s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-23 17:24:42 +09:00
134247b57d
nix: configure target users via nixos
All checks were successful
Build / Create distribution (push) Successful in 2m0s
Test / Run NixOS test (push) Successful in 3m46s
This makes patching home-manager no longer necessary.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-23 17:04:19 +09:00
b5bb7654da
nix: redirect sway output to journal
All checks were successful
Build / Create distribution (push) Successful in 2m8s
Test / Run NixOS test (push) Successful in 3m58s
This makes swaymsg exec output appear in test output.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-23 16:08:22 +09:00
cc1efa22e2
fst: add missing fields to template
All checks were successful
Build / Create distribution (push) Successful in 1m28s
Test / Run NixOS test (push) Successful in 3m43s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-22 12:09:25 +09:00
580128922b
cmd/fpkg: expose syscall policy options
All checks were successful
Build / Create distribution (push) Successful in 1m34s
Test / Run NixOS test (push) Successful in 3m44s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-22 12:01:30 +09:00
23e1152baa
app/share: clean BaseError message
All checks were successful
Build / Create distribution (push) Successful in 1m35s
Test / Run NixOS test (push) Successful in 3m42s
This removes trailing '\n' in the PulseAudio warning.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-22 11:54:16 +09:00
8c51012ef5
dbus: enable syscall filter
All checks were successful
Build / Create distribution (push) Successful in 1m33s
Test / Run NixOS test (push) Successful in 3m42s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-22 11:49:23 +09:00
5a64cdaf4f
ldd: enable syscall filter
All checks were successful
Build / Create distribution (push) Successful in 1m55s
Test / Run NixOS test (push) Successful in 4m6s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-22 02:00:49 +09:00
a30f5e1226
fortify: set up seccomp verbose logging early
All checks were successful
Build / Create distribution (push) Successful in 1m34s
Test / Run NixOS test (push) Successful in 4m4s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-22 01:58:54 +09:00
9a239fa1a5
helper/bwrap: integrate seccomp into helper interface
All checks were successful
Build / Create distribution (push) Successful in 1m36s
Test / Run NixOS test (push) Successful in 3m40s
This makes API usage much cleaner, and encapsulates all bwrap arguments in argsWt.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-22 01:52:57 +09:00
82029948e6
proc: append to ExtraFiles slice pointer
All checks were successful
Build / Create distribution (push) Successful in 1m30s
Test / Run NixOS test (push) Successful in 4m4s
This is useful for initialising extra files before command.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-21 12:51:39 +09:00
dfcdc5ce20
state: store config in separate gob stream
All checks were successful
Build / Create distribution (push) Successful in 1m37s
Test / Run NixOS test (push) Successful in 3m38s
This enables early serialisation of config.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-21 12:10:58 +09:00
fa0616b274
fortify: print permissive defaults warning early
All checks were successful
Build / Create distribution (push) Successful in 1m47s
Test / Run NixOS test (push) Successful in 4m1s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-21 12:05:31 +09:00
20a3d4c458
proc/priv/shim: resolve and load seccomp rules
All checks were successful
Build / Create distribution (push) Successful in 1m33s
Test / Run NixOS test (push) Successful in 3m36s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-20 23:52:56 +09:00
3df344828f
proc/priv/shim: seccomp bpf filter via libseccomp
All checks were successful
Build / Create distribution (push) Successful in 1m59s
Test / Run NixOS test (push) Successful in 4m11s
Rulesets adapted from Flatpak for compatibility.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-20 23:39:47 +09:00
27f5922d5c
fst: include syscall filter configuration
All checks were successful
Build / Create distribution (push) Successful in 3m0s
Test / Run NixOS test (push) Successful in 5m19s
This value is passed through to shim.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-20 21:12:39 +09:00
2cf1f46ea2
nix: test show without --short
All checks were successful
Build / Create distribution (push) Successful in 3m36s
Test / Run NixOS test (push) Successful in 6m45s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-20 21:10:24 +09:00
3c55fc8e86
proc/priv/shim: do not log bwrap args
All checks were successful
Build / Create distribution (push) Successful in 1m22s
Test / Run NixOS test (push) Successful in 3m30s
This message is very long and does not serve much real purpose. Remove it to de-clutter verbose messages.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-20 19:51:28 +09:00
eb0ef2d115
helper/bwrap: generic extra file interface
All checks were successful
Build / Create distribution (push) Successful in 1m32s
Test / Run NixOS test (push) Successful in 3m50s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-20 00:20:04 +09:00
2f70506865
helper/bwrap: move sync to helper state
All checks were successful
Build / Create distribution (push) Successful in 1m25s
Test / Run NixOS test (push) Successful in 3m33s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-19 18:38:13 +09:00
cae567c109
proc/priv/shim: remove unnecessary state
All checks were successful
Build / Create distribution (push) Successful in 1m27s
Test / Run NixOS test (push) Successful in 3m37s
These values are only used during process creation.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-19 18:09:07 +09:00
44 changed files with 1120 additions and 402 deletions

View File

@ -38,6 +38,13 @@ type bundleInfo struct {
// passed through to [fst.Config]
Enablements system.Enablements `json:"enablements"`
// passed through inverted to [bwrap.SyscallPolicy]
Devel bool `json:"devel,omitempty"`
// passed through to [bwrap.SyscallPolicy]
Multiarch bool `json:"multiarch,omitempty"`
// passed through to [bwrap.SyscallPolicy]
Bluetooth bool `json:"bluetooth,omitempty"`
// allow gpu access within sandbox
GPU bool `json:"gpu"`
// store path to nixGL mesa wrappers

View File

@ -5,6 +5,7 @@ import (
"path"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
@ -96,6 +97,7 @@ func actionStart(args []string) {
UserNS: app.UserNS,
Net: app.Net,
Dev: app.Dev,
Syscall: &bwrap.SyscallPolicy{DenyDevel: !app.Devel, Multiarch: app.Multiarch, Bluetooth: app.Bluetooth},
NoNewSession: app.NoNewSession || dropShell,
MapRealUID: app.MapRealUID,
DirectWayland: app.DirectWayland,

View File

@ -5,6 +5,7 @@ import (
"strings"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
@ -34,6 +35,7 @@ func withNixDaemon(
Hostname: formatHostname(app.Name) + "-" + action,
UserNS: true, // nix sandbox requires userns
Net: net,
Syscall: &bwrap.SyscallPolicy{Multiarch: true},
NoNewSession: dropShell,
Filesystem: []*fst.FilesystemConfig{
{Src: pathSet.nixPath, Dst: "/nix", Write: true, Must: true},
@ -65,6 +67,7 @@ func withCacheDir(action string, command []string, workDir string, app *bundleIn
Outer: pathSet.cacheDir, // this also ensures cacheDir via shim
Sandbox: &fst.SandboxConfig{
Hostname: formatHostname(app.Name) + "-" + action,
Syscall: &bwrap.SyscallPolicy{Multiarch: true},
NoNewSession: dropShell,
Filesystem: []*fst.FilesystemConfig{
{Src: path.Join(workDir, "nix"), Dst: "/nix", Must: true},

View File

@ -1,69 +0,0 @@
package main
import (
"bytes"
"errors"
"flag"
"fmt"
"os"
"path"
"strconv"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
func main() {
fmsg.SetPrefix("fuserdb")
const varEmpty = "/var/empty"
out := flag.String("o", "userdb", "output directory")
homeDir := flag.String("d", varEmpty, "parent of home directories")
shell := flag.String("s", "/sbin/nologin", "absolute path to subordinate user shell")
flag.Parse()
type user struct {
name string
fid int
}
users := make([]user, len(flag.Args()))
for i, s := range flag.Args() {
f := bytes.SplitN([]byte(s), []byte{':'}, 2)
if len(f) != 2 {
fmsg.Fatalf("invalid entry at index %d", i)
}
users[i].name = string(f[0])
if fid, err := strconv.Atoi(string(f[1])); err != nil {
fmsg.Fatal(err.Error())
} else {
users[i].fid = fid
}
}
if err := os.MkdirAll(*out, 0755); err != nil && !errors.Is(err, os.ErrExist) {
fmsg.Fatalf("cannot create output: %v", err)
}
for _, u := range users {
fidString := strconv.Itoa(u.fid)
for aid := 0; aid < 10000; aid++ {
userName := fmt.Sprintf("u%d_a%d", u.fid, aid)
uid := 1000000 + u.fid*10000 + aid
us := strconv.Itoa(uid)
realName := fmt.Sprintf("Fortify subordinate user %d (%s)", aid, u.name)
var homeDirectory string
if *homeDir != varEmpty {
homeDirectory = path.Join(*homeDir, "u"+fidString, "a"+strconv.Itoa(aid))
} else {
homeDirectory = varEmpty
}
writeUser(userName, uid, us, realName, homeDirectory, *shell, *out)
writeGroup(userName, uid, us, nil, *out)
}
}
fmsg.Printf("created %d entries", len(users)*2*10000)
fmsg.Exit(0)
}

View File

@ -1,64 +0,0 @@
package main
import (
"encoding/json"
"os"
"path"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
type payloadU struct {
UserName string `json:"userName"`
Uid int `json:"uid"`
Gid int `json:"gid"`
MemberOf []string `json:"memberOf,omitempty"`
RealName string `json:"realName"`
HomeDirectory string `json:"homeDirectory"`
Shell string `json:"shell"`
}
func writeUser(userName string, uid int, us string, realName, homeDirectory, shell string, out string) {
userFileName := userName + ".user"
if f, err := os.OpenFile(path.Join(out, userFileName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644); err != nil {
fmsg.Fatalf("cannot create %s: %v", userName, err)
} else if err = json.NewEncoder(f).Encode(&payloadU{
UserName: userName,
Uid: uid,
Gid: uid,
RealName: realName,
HomeDirectory: homeDirectory,
Shell: shell,
}); err != nil {
fmsg.Fatalf("cannot serialise %s: %v", userName, err)
} else if err = f.Close(); err != nil {
fmsg.Printf("cannot close %s: %v", userName, err)
}
if err := os.Symlink(userFileName, path.Join(out, us+".user")); err != nil {
fmsg.Fatalf("cannot link %s: %v", userName, err)
}
}
type payloadG struct {
GroupName string `json:"groupName"`
Gid int `json:"gid"`
Members []string `json:"members,omitempty"`
}
func writeGroup(groupName string, gid int, gs string, members []string, out string) {
groupFileName := groupName + ".group"
if f, err := os.OpenFile(path.Join(out, groupFileName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644); err != nil {
fmsg.Fatalf("cannot create %s: %v", groupName, err)
} else if err = json.NewEncoder(f).Encode(&payloadG{
GroupName: groupName,
Gid: gid,
Members: members,
}); err != nil {
fmsg.Fatalf("cannot serialise %s: %v", groupName, err)
} else if err = f.Close(); err != nil {
fmsg.Printf("cannot close %s: %v", groupName, err)
}
if err := os.Symlink(groupFileName, path.Join(out, gs+".group")); err != nil {
fmsg.Fatalf("cannot link %s: %v", groupName, err)
}
}

View File

@ -141,7 +141,7 @@ func testProxyStartWaitCloseString(t *testing.T, sandbox bool) {
t.Run("unsealed start of "+id, func(t *testing.T) {
want := "proxy not sealed"
if err := p.Start(nil, 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",
err, errors.New(want))
return
@ -175,7 +175,7 @@ func testProxyStartWaitCloseString(t *testing.T, sandbox bool) {
}
t.Run("sealed start of "+id, func(t *testing.T) {
if err := p.Start(nil, output, sandbox); err != nil {
if err := p.Start(nil, output, sandbox, false); err != nil {
t.Fatalf("Start(nil, nil) error = %v",
err)
}

View File

@ -66,7 +66,7 @@ func (p *Proxy) String() string {
return "(unsealed dbus proxy)"
}
func (p *Proxy) Bwrap() []string {
func (p *Proxy) BwrapStatic() []string {
return p.bwrap.Args()
}

View File

@ -16,7 +16,7 @@ import (
// Start launches the D-Bus proxy and sets up the Wait method.
// ready should be buffered and must only be received from once.
func (p *Proxy) Start(ready chan error, output io.Writer, sandbox bool) error {
func (p *Proxy) Start(ready chan error, output io.Writer, sandbox, seccomp bool) error {
p.lock.Lock()
defer p.lock.Unlock()
@ -67,11 +67,16 @@ func (p *Proxy) Start(ready chan error, output io.Writer, sandbox bool) error {
Unshare: nil,
Hostname: "fortify-dbus",
Chdir: "/",
Syscall: &bwrap.SyscallPolicy{DenyDevel: true, Multiarch: true},
Clearenv: true,
NewSession: true,
DieWithParent: true,
}
if !seccomp {
bc.Syscall = nil
}
// resolve proxy socket directories
bindTarget := make(map[string]struct{}, 2)
for _, ps := range []string{p.session[1], p.system[1]} {
@ -110,7 +115,7 @@ func (p *Proxy) Start(ready chan error, output io.Writer, sandbox bool) error {
bc.Bind(k, k)
}
h = helper.MustNewBwrap(bc, p.seal, toolPath, argF)
h = helper.MustNewBwrap(bc, toolPath, p.seal, argF, nil, nil)
cmd = h.Unwrap()
p.bwrap = bc
}

2
dist/install.sh vendored
View File

@ -4,8 +4,6 @@ cd "$(dirname -- "$0")" || exit 1
install -vDm0755 "bin/fortify" "${FORTIFY_INSTALL_PREFIX}/usr/bin/fortify"
install -vDm0755 "bin/fpkg" "${FORTIFY_INSTALL_PREFIX}/usr/bin/fpkg"
install -vDm0755 "bin/fuserdb" "${FORTIFY_INSTALL_PREFIX}/usr/libexec/fortify/fuserdb"
install -vDm6511 "bin/fsu" "${FORTIFY_INSTALL_PREFIX}/usr/bin/fsu"
if [ ! -f "${FORTIFY_INSTALL_PREFIX}/etc/fsurc" ]; then
install -vDm0400 "fsurc.default" "${FORTIFY_INSTALL_PREFIX}/etc/fsurc"

View File

@ -132,6 +132,7 @@
[
musl
libffi
libseccomp
acl
wayland
wayland-protocols
@ -172,6 +173,7 @@
[
musl
libffi
libseccomp
acl
wayland
wayland-protocols

View File

@ -2,12 +2,13 @@ package fst
import (
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/system"
)
const Tmp = "/.fortify"
// Config is used to seal an *App
// Config is used to seal an app
type Config struct {
// application ID
ID string `json:"id"`
@ -107,9 +108,10 @@ func Template() *Config {
Hostname: "localhost",
UserNS: true,
Net: true,
Dev: true,
Syscall: &bwrap.SyscallPolicy{DenyDevel: true, Multiarch: true},
NoNewSession: true,
MapRealUID: true,
Dev: true,
DirectWayland: false,
// example API credentials pulled from Google Chrome
// DO NOT USE THESE IN A REAL BROWSER
@ -123,7 +125,8 @@ func Template() *Config {
{Src: "/run/current-system"},
{Src: "/run/opengl-driver"},
{Src: "/var/db/nix-channels"},
{Src: "/home/chronos", Write: true, Must: true},
{Src: "/var/lib/fortify/u0/org.chromium.Chromium",
Dst: "/data/data/org.chromium.Chromium", Write: true, Must: true},
{Src: "/dev/dri", Device: true},
},
Link: [][2]string{{"/run/user/65534", "/run/user/150"}},
@ -131,6 +134,10 @@ func Template() *Config {
AutoEtc: true,
Override: []string{"/var/run/nscd"},
},
ExtraPerms: []*ExtraPermConfig{
{Path: "/var/lib/fortify/u0", Ensure: true, Execute: true},
{Path: "/var/lib/fortify/u0/org.chromium.Chromium", Read: true, Write: true, Execute: true},
},
SystemBus: &dbus.Config{
See: nil,
Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"},

View File

@ -22,6 +22,8 @@ type SandboxConfig struct {
Net bool `json:"net,omitempty"`
// share all devices
Dev bool `json:"dev,omitempty"`
// seccomp syscall filter policy
Syscall *bwrap.SyscallPolicy `json:"syscall"`
// do not run in new session
NoNewSession bool `json:"no_new_session,omitempty"`
// map target user uid to privileged user uid in the user namespace
@ -50,6 +52,10 @@ func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
return nil, errors.New("nil sandbox config")
}
if s.Syscall == nil {
fmsg.VPrintln("syscall filter not configured, PROCEED WITH CAUTION")
}
var uid int
if !s.MapRealUID {
uid = 65534
@ -69,6 +75,7 @@ func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
so this capacity should eliminate copies for most setups */
Filesystem: make([]bwrap.FSBuilder, 0, 256),
Syscall: s.Syscall,
NewSession: !s.NoNewSession,
DieWithParent: true,
AsInit: true,

View File

@ -9,7 +9,6 @@ import (
"sync"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/proc"
)
// BubblewrapName is the file name or path to bubblewrap.
@ -21,8 +20,6 @@ type bubblewrap struct {
// bwrap pipes
control *pipes
// sync pipe
sync *os.File
// returns an array of arguments passed directly
// to the child process spawned by bwrap
argF func(argsFD, statFD int) []string
@ -49,11 +46,6 @@ func (b *bubblewrap) StartNotify(ready chan error) error {
return errors.New("exec: already started")
}
// pass sync fd to bwrap
if b.sync != nil {
b.Cmd.Args = append(b.Cmd.Args, "--sync-fd", strconv.Itoa(int(proc.ExtraFile(b.Cmd, b.sync))))
}
// prepare bwrap pipe and args
if argsFD, _, err := b.control.prepareCmd(b.Cmd); err != nil {
return err
@ -119,8 +111,13 @@ func (b *bubblewrap) Unwrap() *exec.Cmd {
// MustNewBwrap initialises a new Bwrap instance with wt as the null-terminated argument writer.
// If wt is nil, the child process spawned by bwrap will not get an argument pipe.
// Function argF returns an array of arguments passed directly to the child process.
func MustNewBwrap(conf *bwrap.Config, wt io.WriterTo, name string, argF func(argsFD, statFD int) []string) Helper {
b, err := NewBwrap(conf, wt, name, argF)
func MustNewBwrap(
conf *bwrap.Config, name string,
wt io.WriterTo, argF func(argsFD, statFD int) []string,
extraFiles []*os.File,
syncFd *os.File,
) Helper {
b, err := NewBwrap(conf, name, wt, argF, extraFiles, syncFd)
if err != nil {
panic(err.Error())
} else {
@ -131,22 +128,30 @@ func MustNewBwrap(conf *bwrap.Config, wt io.WriterTo, name string, argF func(arg
// NewBwrap initialises a new Bwrap instance with wt as the null-terminated argument writer.
// If wt is nil, the child process spawned by bwrap will not get an argument pipe.
// Function argF returns an array of arguments passed directly to the child process.
func NewBwrap(conf *bwrap.Config, wt io.WriterTo, name string, argF func(argsFD, statFD int) []string) (Helper, error) {
func NewBwrap(
conf *bwrap.Config, name string,
wt io.WriterTo, argF func(argsFD, statFD int) []string,
extraFiles []*os.File,
syncFd *os.File,
) (Helper, error) {
b := new(bubblewrap)
if args, err := NewCheckedArgs(conf.Args()); err != nil {
return nil, err
} else {
b.control = &pipes{args: args}
}
b.sync = conf.Sync()
b.argF = argF
b.name = name
if wt != nil {
b.controlPt = &pipes{args: wt}
}
b.Cmd = execCommand(BubblewrapName)
b.control = new(pipes)
args := conf.Args()
if fdArgs, err := conf.FDArgs(syncFd, &extraFiles); err != nil {
return nil, err
} else if b.control.args, err = NewCheckedArgs(append(args, fdArgs...)); err != nil {
return nil, err
} else {
b.Cmd.ExtraFiles = extraFiles
}
return b, nil
}

View File

@ -1,6 +1,13 @@
package bwrap
import "encoding/gob"
import (
"encoding/gob"
"os"
"slices"
"strconv"
"git.gensokyo.uk/security/fortify/internal/proc"
)
type Builder interface {
Len() int
@ -12,6 +19,11 @@ type FSBuilder interface {
Builder
}
type FDBuilder interface {
Len() int
Append(args *[]string, extraFiles *[]*os.File) error
}
func init() {
gob.Register(new(pairF))
gob.Register(new(stringF))
@ -45,6 +57,33 @@ 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.
func (c *Config) Args() (args []string) {
builders := []Builder{
@ -75,3 +114,25 @@ func (c *Config) Args() (args []string) {
return
}
func (c *Config) FDArgs(syncFd *os.File, extraFiles *[]*os.File) (args []string, err error) {
builders := []FDBuilder{
&seccompBuilder{c},
&fileF{positionalArgs[SyncFd], syncFd},
}
argc := 0
for _, b := range builders {
argc += b.Len()
}
args = make([]string, 0, argc)
*extraFiles = slices.Grow(*extraFiles, len(builders))
for _, b := range builders {
if err = b.Append(&args, extraFiles); err != nil {
break
}
}
return
}

View File

@ -161,10 +161,3 @@ func (c *Config) SetGID(gid int) *Config {
}
return c
}
// SetSync sets the sync pipe kept open while sandbox is running
// (--sync-fd FD)
func (c *Config) SetSync(s *os.File) *Config {
c.sync = s
return c
}

View File

@ -1,9 +1,5 @@
package bwrap
import (
"os"
)
type Config struct {
// unshare every namespace we support by default if nil
// (--unshare-all)
@ -51,6 +47,10 @@ type Config struct {
// (--chmod OCTAL PATH)
Chmod ChmodConfig `json:"chmod,omitempty"`
// load and use seccomp rules from FD (not repeatable)
// (--seccomp FD)
Syscall *SyscallPolicy
// create a new terminal session
// (--new-session)
NewSession bool `json:"new_session"`
@ -61,10 +61,6 @@ type Config struct {
// (--as-pid-1)
AsInit bool `json:"as_init"`
// keep this fd open while sandbox is running
// (--sync-fd FD)
sync *os.File
/* unmapped options include:
--unshare-user-try Create new user namespace if possible else continue by skipping it
--unshare-cgroup-try Create new cgroup namespace if possible else continue by skipping it
@ -78,7 +74,6 @@ type Config struct {
--file FD DEST Copy from FD to destination DEST
--bind-data FD DEST Copy from FD to file which is bind-mounted on DEST
--ro-bind-data FD DEST Copy from FD to file which is readonly bind-mounted on DEST
--seccomp FD Load and use seccomp rules from FD (not repeatable)
--add-seccomp-fd FD Load and use seccomp rules from FD (repeatable)
--block-fd FD Block on FD until some data to read is available
--userns-block-fd FD Block on FD until the user namespace is ready
@ -90,12 +85,6 @@ type Config struct {
among which --args is used internally for passing arguments */
}
// Sync keep this fd open while sandbox is running
// (--sync-fd FD)
func (c *Config) Sync() *os.File {
return c.sync
}
type UnshareConfig struct {
// (--unshare-user)
// create new user namespace

View File

@ -126,8 +126,7 @@ func TestConfig_Args(t *testing.T) {
name: "uid gid sync",
conf: (new(bwrap.Config)).
SetUID(1971).
SetGID(100).
SetSync(os.Stdin),
SetGID(100),
want: []string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
@ -135,8 +134,6 @@ func TestConfig_Args(t *testing.T) {
"--uid", "1971",
// SetGID(100)
"--gid", "100",
// SetSync(os.Stdin)
// this is set when the process is created
},
},
{
@ -246,10 +243,4 @@ func TestConfig_Args(t *testing.T) {
}()
(new(bwrap.Config)).Persist("/run", "", "")
})
t.Run("sync file", func(t *testing.T) {
if s := (new(bwrap.Config)).SetSync(os.Stdout).Sync(); s != os.Stdout {
t.Errorf("Sync() = %v", s)
}
})
}

View File

@ -0,0 +1,254 @@
#ifndef _GNU_SOURCE
#define _GNU_SOURCE // CLONE_NEWUSER
#endif
#include "seccomp-export.h"
#include <stdlib.h>
#include <stdio.h>
#include <assert.h>
#include <errno.h>
#include <sys/syscall.h>
#include <sys/socket.h>
#include <sys/ioctl.h>
#include <sys/personality.h>
#include <sched.h>
#if (SCMP_VER_MAJOR < 2) || \
(SCMP_VER_MAJOR == 2 && SCMP_VER_MINOR < 5) || \
(SCMP_VER_MAJOR == 2 && SCMP_VER_MINOR == 5 && SCMP_VER_MICRO < 1)
#error This package requires libseccomp >= v2.5.1
#endif
struct f_syscall_act {
int syscall;
int m_errno;
struct scmp_arg_cmp *arg;
};
#define LEN(arr) (sizeof(arr) / sizeof((arr)[0]))
#define SECCOMP_RULESET_ADD(ruleset) do { \
F_println("adding seccomp ruleset \"" #ruleset "\""); \
for (int i = 0; i < LEN(ruleset); i++) { \
assert(ruleset[i].m_errno == EPERM || ruleset[i].m_errno == ENOSYS); \
\
if (ruleset[i].arg) \
ret = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(ruleset[i].m_errno), ruleset[i].syscall, 1, *ruleset[i].arg); \
else \
ret = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(ruleset[i].m_errno), ruleset[i].syscall, 0); \
\
if (ret == -EFAULT) { \
res = 4; \
goto out; \
} else if (ret < 0) { \
res = 5; \
errno = -ret; \
goto out; \
} \
} \
} 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 res = 0; // refer to resErr for meaning
int allow_multiarch = opts & F_MULTIARCH;
int allowed_personality = PER_LINUX;
if (opts & F_LINUX32)
allowed_personality = PER_LINUX32;
// flatpak commit 4c3bf179e2e4a2a298cd1db1d045adaf3f564532
struct f_syscall_act deny_common[] = {
// Block dmesg
{SCMP_SYS(syslog), EPERM},
// Useless old syscall
{SCMP_SYS(uselib), EPERM},
// Don't allow disabling accounting
{SCMP_SYS(acct), EPERM},
// Don't allow reading current quota use
{SCMP_SYS(quotactl), EPERM},
// Don't allow access to the kernel keyring
{SCMP_SYS(add_key), EPERM},
{SCMP_SYS(keyctl), EPERM},
{SCMP_SYS(request_key), EPERM},
// Scary VM/NUMA ops
{SCMP_SYS(move_pages), EPERM},
{SCMP_SYS(mbind), EPERM},
{SCMP_SYS(get_mempolicy), EPERM},
{SCMP_SYS(set_mempolicy), EPERM},
{SCMP_SYS(migrate_pages), EPERM},
};
struct f_syscall_act deny_ns[] = {
// Don't allow subnamespace setups:
{SCMP_SYS(unshare), EPERM},
{SCMP_SYS(setns), EPERM},
{SCMP_SYS(mount), EPERM},
{SCMP_SYS(umount), EPERM},
{SCMP_SYS(umount2), EPERM},
{SCMP_SYS(pivot_root), EPERM},
{SCMP_SYS(chroot), EPERM},
#if defined(__s390__) || defined(__s390x__) || defined(__CRIS__)
// Architectures with CONFIG_CLONE_BACKWARDS2: the child stack
// and flags arguments are reversed so the flags come second
{SCMP_SYS(clone), EPERM, &SCMP_A1(SCMP_CMP_MASKED_EQ, CLONE_NEWUSER, CLONE_NEWUSER)},
#else
// Normally the flags come first
{SCMP_SYS(clone), EPERM, &SCMP_A0(SCMP_CMP_MASKED_EQ, CLONE_NEWUSER, CLONE_NEWUSER)},
#endif
// seccomp can't look into clone3()'s struct clone_args to check whether
// the flags are OK, so we have no choice but to block clone3().
// Return ENOSYS so user-space will fall back to clone().
// (CVE-2021-41133; see also https://github.com/moby/moby/commit/9f6b562d)
{SCMP_SYS(clone3), ENOSYS},
// New mount manipulation APIs can also change our VFS. There's no
// legitimate reason to do these in the sandbox, so block all of them
// rather than thinking about which ones might be dangerous.
// (CVE-2021-41133)
{SCMP_SYS(open_tree), ENOSYS},
{SCMP_SYS(move_mount), ENOSYS},
{SCMP_SYS(fsopen), ENOSYS},
{SCMP_SYS(fsconfig), ENOSYS},
{SCMP_SYS(fsmount), ENOSYS},
{SCMP_SYS(fspick), ENOSYS},
{SCMP_SYS(mount_setattr), ENOSYS},
};
struct f_syscall_act deny_tty[] = {
// Don't allow faking input to the controlling tty (CVE-2017-5226)
{SCMP_SYS(ioctl), EPERM, &SCMP_A1(SCMP_CMP_MASKED_EQ, 0xFFFFFFFFu, (int)TIOCSTI)},
// In the unlikely event that the controlling tty is a Linux virtual
// console (/dev/tty2 or similar), copy/paste operations have an effect
// similar to TIOCSTI (CVE-2023-28100)
{SCMP_SYS(ioctl), EPERM, &SCMP_A1(SCMP_CMP_MASKED_EQ, 0xFFFFFFFFu, (int)TIOCLINUX)},
};
struct f_syscall_act deny_devel[] = {
// Profiling operations; we expect these to be done by tools from outside
// the sandbox. In particular perf has been the source of many CVEs.
{SCMP_SYS(perf_event_open), EPERM},
// Don't allow you to switch to bsd emulation or whatnot
{SCMP_SYS(personality), EPERM, &SCMP_A0(SCMP_CMP_NE, allowed_personality)},
{SCMP_SYS(ptrace), EPERM}
};
// Blocklist all but unix, inet, inet6 and netlink
struct
{
int family;
f_syscall_opts flags_mask;
} socket_family_allowlist[] = {
// NOTE: Keep in numerical order
{ AF_UNSPEC, 0 },
{ AF_LOCAL, 0 },
{ AF_INET, 0 },
{ AF_INET6, 0 },
{ AF_NETLINK, 0 },
{ AF_CAN, F_CAN },
{ AF_BLUETOOTH, F_BLUETOOTH },
};
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW);
if (ctx == NULL) {
res = 1;
goto out;
} else
errno = 0;
int ret;
// We only really need to handle arches on multiarch systems.
// If only one arch is supported the default is fine
if (arch != 0) {
// This *adds* the target arch, instead of replacing the
// native one. This is not ideal, because we'd like to only
// allow the target arch, but we can't really disallow the
// native arch at this point, because then bubblewrap
// couldn't continue running.
ret = seccomp_arch_add(ctx, arch);
if (ret < 0 && ret != -EEXIST) {
res = 2;
errno = -ret;
goto out;
}
if (allow_multiarch && multiarch != 0) {
ret = seccomp_arch_add(ctx, multiarch);
if (ret < 0 && ret != -EEXIST) {
res = 3;
errno = -ret;
goto out;
}
}
}
SECCOMP_RULESET_ADD(deny_common);
if (opts & F_DENY_NS) SECCOMP_RULESET_ADD(deny_ns);
if (opts & F_DENY_TTY) SECCOMP_RULESET_ADD(deny_tty);
if (opts & F_DENY_DEVEL) SECCOMP_RULESET_ADD(deny_devel);
if (!allow_multiarch) {
F_println("disabling modify_ldt");
// modify_ldt is a historic source of interesting information leaks,
// so it's disabled as a hardening measure.
// However, it is required to run old 16-bit applications
// as well as some Wine patches, so it's allowed in multiarch.
ret = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(EPERM), SCMP_SYS(modify_ldt), 0);
// See above for the meaning of EFAULT.
if (ret == -EFAULT) {
// call fmsg here?
res = 4;
goto out;
} else if (ret < 0) {
res = 5;
errno = -ret;
goto out;
}
}
// Socket filtering doesn't work on e.g. i386, so ignore failures here
// However, we need to user seccomp_rule_add_exact to avoid libseccomp doing
// something else: https://github.com/seccomp/libseccomp/issues/8
int last_allowed_family = -1;
for (int i = 0; i < LEN(socket_family_allowlist); i++) {
if (socket_family_allowlist[i].flags_mask != 0 &&
(socket_family_allowlist[i].flags_mask & opts) != socket_family_allowlist[i].flags_mask)
continue;
for (int disallowed = last_allowed_family + 1; disallowed < socket_family_allowlist[i].family; disallowed++) {
// Blocklist the in-between valid families
seccomp_rule_add_exact(ctx, SCMP_ACT_ERRNO(EAFNOSUPPORT), SCMP_SYS(socket), 1, SCMP_A0(SCMP_CMP_EQ, disallowed));
}
last_allowed_family = socket_family_allowlist[i].family;
}
// Blocklist the rest
seccomp_rule_add_exact(ctx, SCMP_ACT_ERRNO(EAFNOSUPPORT), SCMP_SYS(socket), 1, SCMP_A0(SCMP_CMP_GE, last_allowed_family + 1));
ret = seccomp_export_bpf(ctx, fd);
if (ret != 0) {
res = 6;
errno = -ret;
goto out;
}
out:
if (ctx)
seccomp_release(ctx);
return res;
}

View File

@ -0,0 +1,22 @@
#include <stdint.h>
#include <seccomp.h>
#if (SCMP_VER_MAJOR < 2) || \
(SCMP_VER_MAJOR == 2 && SCMP_VER_MINOR < 5) || \
(SCMP_VER_MAJOR == 2 && SCMP_VER_MINOR == 5 && SCMP_VER_MICRO < 1)
#error This package requires libseccomp >= v2.5.1
#endif
typedef enum {
F_DENY_NS = 1 << 0,
F_DENY_TTY = 1 << 1,
F_DENY_DEVEL = 1 << 2,
F_MULTIARCH = 1 << 3,
F_LINUX32 = 1 << 4,
F_CAN = 1 << 5,
F_BLUETOOTH = 1 << 6,
} f_syscall_opts;
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);

View File

@ -0,0 +1,95 @@
package bwrap
import (
"fmt"
"io"
"os"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
type SyscallPolicy struct {
DenyDevel bool `json:"deny_devel"`
Multiarch bool `json:"multiarch"`
Linux32 bool `json:"linux32"`
Can bool `json:"can"`
Bluetooth bool `json:"bluetooth"`
}
type seccompBuilder struct {
config *Config
}
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 (
opts syscallOpts
optd []string
optCond = [...]struct {
v bool
o syscallOpts
d string
}{
{!c.UserNS, flagDenyNS, "denyns"},
{c.NewSession, flagDenyTTY, "denytty"},
{c.Syscall.DenyDevel, flagDenyDevel, "denydevel"},
{c.Syscall.Multiarch, flagMultiarch, "multiarch"},
{c.Syscall.Linux32, flagLinux32, "linux32"},
{c.Syscall.Can, flagCan, "can"},
{c.Syscall.Bluetooth, flagBluetooth, "bluetooth"},
}
)
if CPrintln != nil {
optd = make([]string, 1, len(optCond)+1)
optd[0] = "common"
}
for _, opt := range optCond {
if opt.v {
opts |= opt.o
if fmsg.Verbose() {
optd = append(optd, opt.d)
}
}
}
if CPrintln != nil {
CPrintln(fmt.Sprintf("seccomp flags: %s", optd))
}
// export seccomp filter to tmpfile
if f, err := tmpfile(); err != nil {
return nil, err
} else {
return f, exportAndSeek(f, opts)
}
}
func exportAndSeek(f *os.File, opts syscallOpts) error {
if err := exportFilter(f.Fd(), opts); err != nil {
return err
}
_, err := f.Seek(0, io.SeekStart)
return err
}

83
helper/bwrap/seccomp.go Normal file
View File

@ -0,0 +1,83 @@
package bwrap
/*
#cgo linux pkg-config: --static libseccomp
#include "seccomp-export.h"
*/
import "C"
import (
"errors"
"fmt"
"os"
"runtime"
)
var CPrintln func(v ...any)
var resErr = [...]error{
0: nil,
1: errors.New("seccomp_init failed"),
2: errors.New("seccomp_arch_add failed"),
3: errors.New("seccomp_arch_add failed (multiarch)"),
4: errors.New("internal libseccomp failure"),
5: errors.New("seccomp_rule_add failed"),
6: errors.New("seccomp_export_bpf failed"),
}
type (
syscallOpts = C.f_syscall_opts
)
const (
flagDenyNS syscallOpts = C.F_DENY_NS
flagDenyTTY syscallOpts = C.F_DENY_TTY
flagDenyDevel syscallOpts = C.F_DENY_DEVEL
flagMultiarch syscallOpts = C.F_MULTIARCH
flagLinux32 syscallOpts = C.F_LINUX32
flagCan syscallOpts = C.F_CAN
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 {
var (
arch C.uint32_t = 0
multiarch C.uint32_t = 0
)
switch runtime.GOARCH {
case "386":
arch = C.SCMP_ARCH_X86
case "amd64":
arch = C.SCMP_ARCH_X86_64
multiarch = C.SCMP_ARCH_X86
case "arm":
arch = C.SCMP_ARCH_ARM
case "arm64":
arch = C.SCMP_ARCH_AARCH64
multiarch = C.SCMP_ARCH_ARM
}
res, err := C.f_export_bpf(C.int(fd), arch, multiarch, opts)
if re := resErr[res]; re != nil {
if err == nil {
return re
}
return fmt.Errorf("%s: %v", re.Error(), err)
}
return err
}
//export F_println
func F_println(v *C.char) {
if CPrintln != nil {
CPrintln(C.GoString(v))
}
}

View File

@ -43,6 +43,9 @@ const (
Overlay
TmpOverlay
ROOverlay
SyncFd
Seccomp
)
var positionalArgs = [...]string{
@ -70,6 +73,9 @@ var positionalArgs = [...]string{
Overlay: "--overlay",
TmpOverlay: "--tmp-overlay",
ROOverlay: "--ro-overlay",
SyncFd: "--sync-fd",
Seccomp: "--seccomp",
}
type PermConfig[T FSBuilder] struct {

View File

@ -31,7 +31,11 @@ func TestBwrap(t *testing.T) {
helper.BubblewrapName = bubblewrapName
})
h := helper.MustNewBwrap(sc, argsWt, "fortify", argF)
h := helper.MustNewBwrap(
sc, "fortify",
argsWt, argF,
nil, nil,
)
if err := h.Start(); !errors.Is(err, os.ErrNotExist) {
t.Errorf("Start() error = %v, wantErr %v",
@ -40,7 +44,11 @@ func TestBwrap(t *testing.T) {
})
t.Run("valid new helper nil check", func(t *testing.T) {
if got := helper.MustNewBwrap(sc, argsWt, "fortify", argF); got == nil {
if got := helper.MustNewBwrap(
sc, "fortify",
argsWt, argF,
nil, nil,
); got == nil {
t.Errorf("MustNewBwrap(%#v, %#v, %#v) got nil",
sc, argsWt, "fortify")
return
@ -56,7 +64,11 @@ func TestBwrap(t *testing.T) {
}
}()
helper.MustNewBwrap(&bwrap.Config{Hostname: "\x00"}, nil, "fortify", argF)
helper.MustNewBwrap(
&bwrap.Config{Hostname: "\x00"}, "fortify",
nil, argF,
nil, nil,
)
})
t.Run("start notify without pipes panic", func(t *testing.T) {
@ -69,13 +81,21 @@ func TestBwrap(t *testing.T) {
}()
panic(fmt.Sprintf("unreachable: %v",
helper.MustNewBwrap(sc, nil, "fortify", argF).StartNotify(make(chan error))))
helper.MustNewBwrap(
sc, "fortify",
nil, argF,
nil, nil,
).StartNotify(make(chan error))))
})
t.Run("start without pipes", func(t *testing.T) {
helper.InternalReplaceExecCommand(t)
h := helper.MustNewBwrap(sc, nil, "crash-test-dummy", argFChecked)
h := helper.MustNewBwrap(
sc, "crash-test-dummy",
nil, argFChecked,
nil, nil,
)
cmd := h.Unwrap()
stdout, stderr := new(strings.Builder), new(strings.Builder)
@ -107,6 +127,6 @@ func TestBwrap(t *testing.T) {
})
t.Run("implementation compliance", func(t *testing.T) {
testHelper(t, func() helper.Helper { return helper.MustNewBwrap(sc, argsWt, "crash-test-dummy", argF) })
testHelper(t, func() helper.Helper { return helper.MustNewBwrap(sc, "crash-test-dummy", argsWt, argF, nil, nil) })
})
}

View File

@ -3,7 +3,6 @@ package app
import (
"context"
"sync"
"sync/atomic"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/linux"
@ -30,9 +29,6 @@ type RunState struct {
}
type app struct {
// single-use config reference
ct *appCt
// application unique identifier
id *fst.ID
// operating system interface
@ -74,24 +70,3 @@ func New(os linux.System) (App, error) {
a.os = os
return a, fst.NewAppID(a.id)
}
// appCt ensures its wrapped val is only accessed once
type appCt struct {
val *fst.Config
done *atomic.Bool
}
func (a *appCt) Unwrap() *fst.Config {
if !a.done.Load() {
defer a.done.Store(true)
return a.val
}
panic("attempted to access config reference twice")
}
func newAppCt(config *fst.Config) (ct *appCt) {
ct = new(appCt)
ct.done = new(atomic.Bool)
ct.val = config
return ct
}

View File

@ -39,6 +39,7 @@ var testCasesPd = []sealTestCase{
Net: true,
UserNS: true,
Clearenv: true,
Syscall: new(bwrap.SyscallPolicy),
Chdir: "/home/chronos",
SetEnv: map[string]string{
"HOME": "/home/chronos",
@ -258,6 +259,7 @@ var testCasesPd = []sealTestCase{
UserNS: true,
Chdir: "/home/chronos",
Clearenv: true,
Syscall: new(bwrap.SyscallPolicy),
SetEnv: map[string]string{
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/65534/bus",
"DBUS_SYSTEM_BUS_ADDRESS": "unix:path=/run/dbus/system_bus_socket",

View File

@ -1,8 +1,11 @@
package app
import (
"bytes"
"encoding/gob"
"errors"
"fmt"
"io"
"io/fs"
"path"
"regexp"
@ -11,6 +14,7 @@ import (
"git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/linux"
"git.gensokyo.uk/security/fortify/internal/state"
@ -47,6 +51,8 @@ type appSeal struct {
// pass-through enablement tracking from config
et system.Enablements
// initial config gob encoding buffer
ct io.WriterTo
// wayland socket direct access
directWayland bool
// extra UpdatePerm ops
@ -85,6 +91,14 @@ func (a *app) Seal(config *fst.Config) error {
// create seal
seal := new(appSeal)
// encode initial configuration for state tracking
ct := new(bytes.Buffer)
if err := gob.NewEncoder(ct).Encode(config); err != nil {
return fmsg.WrapErrorSuffix(err,
"cannot encode initial config:")
}
seal.ct = ct
// fetch system constants
seal.Paths = a.os.Paths()
@ -181,6 +195,7 @@ func (a *app) Seal(config *fst.Config) error {
conf := &fst.SandboxConfig{
UserNS: true,
Net: true,
Syscall: new(bwrap.SyscallPolicy),
NoNewSession: true,
AutoEtc: true,
}
@ -252,6 +267,5 @@ func (a *app) Seal(config *fst.Config) error {
// seal app and release lock
a.seal = seal
a.ct = newAppCt(config)
return nil
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"path"
"strings"
"git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus"
@ -240,7 +241,7 @@ func (seal *appSeal) setupShares(bus [2]*dbus.Config, os linux.System) error {
// publish current user's pulse cookie for target user
if src, err := discoverPulseCookie(os); err != nil {
// not fatal
fmsg.VPrintln(err.(*fmsg.BaseError).Message())
fmsg.VPrintln(strings.TrimSpace(err.(*fmsg.BaseError).Message()))
} else {
dst := path.Join(seal.share, "pulse-cookie")
innerDst := fst.Tmp + "/pulse-cookie"

View File

@ -45,33 +45,20 @@ func (a *app) Run(ctx context.Context, rs *RunState) error {
}
}
// construct shim manager
a.shim = shim.New(
uint32(a.seal.sys.UID()),
a.seal.sys.user.as,
a.seal.sys.user.supp,
&shim.Payload{
Argv: a.seal.command,
Exec: shimExec,
Bwrap: a.seal.sys.bwrap,
Home: a.seal.sys.user.data,
Verbose: fmsg.Verbose(),
},
)
// startup will go ahead, commit system setup
if err := a.seal.sys.Commit(); err != nil {
return err
}
a.seal.sys.needRevert = true
// export sync pipe from sys
a.seal.sys.bwrap.SetSync(a.seal.sys.Sync())
// start shim via manager
a.shim = new(shim.Shim)
waitErr := make(chan error, 1)
if startTime, err := a.shim.Start(); err != nil {
if startTime, err := a.shim.Start(
a.seal.sys.user.as,
a.seal.sys.user.supp,
a.seal.sys.Sync(),
); err != nil {
return err
} else {
// shim process created
@ -88,22 +75,28 @@ func (a *app) Run(ctx context.Context, rs *RunState) error {
}()
// send payload
if err = a.shim.Serve(shimSetupCtx); err != nil {
if err = a.shim.Serve(shimSetupCtx, &shim.Payload{
Argv: a.seal.command,
Exec: shimExec,
Bwrap: a.seal.sys.bwrap,
Home: a.seal.sys.user.data,
Verbose: fmsg.Verbose(),
}); err != nil {
return err
}
// shim accepted setup payload, create process state
sd := state.State{
ID: *a.id,
PID: a.shim.Unwrap().Process.Pid,
Config: a.ct.Unwrap(),
Time: *startTime,
ID: *a.id,
PID: a.shim.Unwrap().Process.Pid,
Time: *startTime,
}
// register process state
var err0 = new(StateStoreError)
err0.Inner, err0.DoErr = a.seal.store.Do(a.seal.sys.user.aid, func(c state.Cursor) {
err0.InnerErr = c.Save(&sd)
err0.InnerErr = c.Save(&sd, a.seal.ct)
})
a.seal.sys.saveState = true
if err = err0.equiv("cannot save process state:"); err != nil {

View File

@ -6,8 +6,12 @@ import (
)
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(cmd.ExtraFiles))
cmd.ExtraFiles = append(cmd.ExtraFiles, f)
fd = uintptr(3 + len(*extraFiles))
*extraFiles = append(*extraFiles, f)
return
}

View File

@ -5,10 +5,10 @@ import (
"os"
"path"
"strconv"
"syscall"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/proc"
@ -29,14 +29,6 @@ func Main() {
panic("unreachable")
}
// re-exec
if len(os.Args) > 0 && (os.Args[0] != "fortify" || os.Args[1] != "shim" || len(os.Args) != 2) && path.IsAbs(os.Args[0]) {
if err := syscall.Exec(os.Args[0], []string{"fortify", "shim"}, os.Environ()); err != nil {
fmsg.Println("cannot re-exec self:", err)
// continue anyway
}
}
// receive setup payload
var (
payload Payload
@ -62,8 +54,9 @@ func Main() {
}
// restore bwrap sync fd
var syncFd *os.File
if payload.Sync != nil {
payload.Bwrap.SetSync(os.NewFile(*payload.Sync, "sync"))
syncFd = os.NewFile(*payload.Sync, "sync")
}
// close setup socket
@ -134,17 +127,19 @@ func Main() {
conf.Symlink("fortify", innerInit)
helper.BubblewrapName = payload.Exec[0] // resolved bwrap path by parent
if b, err := helper.NewBwrap(conf, nil, innerInit,
func(int, int) []string { return make([]string, 0) }); err != nil {
if fmsg.Verbose() {
bwrap.CPrintln = fmsg.Println
}
if b, err := helper.NewBwrap(
conf, innerInit,
nil, func(int, int) []string { return make([]string, 0) },
extraFiles,
syncFd,
); err != nil {
fmsg.Fatalf("malformed sandbox config: %v", err)
} else {
cmd := b.Unwrap()
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.ExtraFiles = extraFiles
if fmsg.Verbose() {
fmsg.VPrintln("bwrap args:", conf.Args())
}
// run and pass through exit code
if err = b.Start(); err != nil {

View File

@ -20,22 +20,12 @@ import (
type Shim struct {
// user switcher process
cmd *exec.Cmd
// uid of shim target user
uid uint32
// string representation of application id
aid string
// string representation of supplementary group ids
supp []string
// fallback exit notifier with error returned killing the process
killFallback chan error
// shim setup payload
payload *Payload
// monitor to shim encoder
encoder *gob.Encoder
}
func New(uid uint32, aid string, supp []string, payload *Payload) *Shim {
return &Shim{uid: uid, aid: aid, supp: supp, payload: payload}
// bwrap --sync-fd value
sync *uintptr
}
func (s *Shim) String() string {
@ -53,7 +43,14 @@ func (s *Shim) WaitFallback() chan error {
return s.killFallback
}
func (s *Shim) Start() (*time.Time, error) {
func (s *Shim) Start(
// string representation of application id
aid string,
// string representation of supplementary group ids
supp []string,
// bwrap --sync-fd
syncFd *os.File,
) (*time.Time, error) {
// prepare user switcher invocation
var fsu string
if p, ok := internal.Path(internal.Fsu); !ok {
@ -72,22 +69,22 @@ func (s *Shim) Start() (*time.Time, error) {
s.encoder = e
s.cmd.Env = []string{
Env + "=" + strconv.Itoa(fd),
"FORTIFY_APP_ID=" + s.aid,
"FORTIFY_APP_ID=" + aid,
}
}
// format fsu supplementary groups
if len(s.supp) > 0 {
fmsg.VPrintf("attaching supplementary group ids %s", s.supp)
s.cmd.Env = append(s.cmd.Env, "FORTIFY_GROUPS="+strings.Join(s.supp, " "))
if len(supp) > 0 {
fmsg.VPrintf("attaching supplementary group ids %s", supp)
s.cmd.Env = append(s.cmd.Env, "FORTIFY_GROUPS="+strings.Join(supp, " "))
}
s.cmd.Stdin, s.cmd.Stdout, s.cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
s.cmd.Dir = "/"
// pass sync fd if set
if s.payload.Bwrap.Sync() != nil {
fd := proc.ExtraFile(s.cmd, s.payload.Bwrap.Sync())
s.payload.Sync = &fd
if syncFd != nil {
fd := proc.ExtraFile(s.cmd, syncFd)
s.sync = &fd
}
fmsg.VPrintln("starting shim via fsu:", s.cmd)
@ -101,7 +98,7 @@ func (s *Shim) Start() (*time.Time, error) {
return &startTime, nil
}
func (s *Shim) Serve(ctx context.Context) error {
func (s *Shim) Serve(ctx context.Context, payload *Payload) error {
// kill shim if something goes wrong and an error is returned
s.killFallback = make(chan error, 1)
killShim := func() {
@ -111,8 +108,9 @@ func (s *Shim) Serve(ctx context.Context) error {
}
defer func() { killShim() }()
payload.Sync = s.sync
encodeErr := make(chan error)
go func() { encodeErr <- s.encoder.Encode(s.payload) }()
go func() { encodeErr <- s.encoder.Encode(payload) }()
select {
// encode return indicates setup completion

View File

@ -1,6 +1,8 @@
package shim
import "git.gensokyo.uk/security/fortify/helper/bwrap"
import (
"git.gensokyo.uk/security/fortify/helper/bwrap"
)
const Env = "FORTIFY_SHIM"

View File

@ -1,9 +1,11 @@
package state
import (
"encoding/binary"
"encoding/gob"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
@ -208,12 +210,11 @@ func (b *multiBackend) load(decode bool) (Entries, error) {
s := new(State)
r[*id] = s
// append regardless, but only parse if required, used to implement Len
// append regardless, but only parse if required, implements Len
if decode {
if err = gob.NewDecoder(f).Decode(s); err != nil {
if err = b.decodeState(f, s); err != nil {
return err
}
if s.ID != *id {
return fmt.Errorf("state entry %s has unexpected id %s", id, &s.ID)
}
@ -229,18 +230,65 @@ func (b *multiBackend) load(decode bool) (Entries, error) {
return r, nil
}
// state file consists of an eight byte header, followed by concatenated gobs
// of [fst.Config] and [State], if [State.Config] is not nil or offset < 0,
// the first gob is skipped
func (b *multiBackend) decodeState(r io.ReadSeeker, state *State) error {
offset := make([]byte, 8)
if l, err := r.Read(offset); err != nil {
if errors.Is(err, io.EOF) {
return fmt.Errorf("state file too short: %d bytes", l)
}
return err
}
// decode volatile state first
var skipConfig bool
{
o := int64(binary.LittleEndian.Uint64(offset))
skipConfig = o < 0
if !skipConfig {
if l, err := r.Seek(o, io.SeekCurrent); err != nil {
return err
} else if l != 8+o {
return fmt.Errorf("invalid seek offset %d", l)
}
}
}
if err := gob.NewDecoder(r).Decode(state); err != nil {
return err
}
// decode sealed config
if state.Config == nil {
// config must be provided either as part of volatile state,
// or in the config segment
if skipConfig {
return ErrNoConfig
}
state.Config = new(fst.Config)
if _, err := r.Seek(8, io.SeekStart); err != nil {
return err
}
return gob.NewDecoder(r).Decode(state.Config)
} else {
return nil
}
}
// Save writes process state to filesystem
func (b *multiBackend) Save(state *State) error {
func (b *multiBackend) Save(state *State, configWriter io.WriterTo) error {
b.lock.Lock()
defer b.lock.Unlock()
if state.Config == nil {
return errors.New("state does not contain config")
if configWriter == nil && state.Config == nil {
return ErrNoConfig
}
statePath := b.filename(&state.ID)
// create and open state data file
if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil {
return err
} else {
@ -250,11 +298,43 @@ func (b *multiBackend) Save(state *State) error {
panic("state file closed prematurely")
}
}()
// encode into state file
return gob.NewEncoder(f).Encode(state)
return b.encodeState(f, state, configWriter)
}
}
func (b *multiBackend) encodeState(w io.WriteSeeker, state *State, configWriter io.WriterTo) error {
offset := make([]byte, 8)
// skip header bytes
if _, err := w.Seek(8, io.SeekStart); err != nil {
return err
}
if configWriter != nil {
// write config gob and encode header
if l, err := configWriter.WriteTo(w); err != nil {
return err
} else {
binary.LittleEndian.PutUint64(offset, uint64(l))
}
} else {
// offset == -1 indicates absence of config gob
binary.LittleEndian.PutUint64(offset, 0xffffffffffffffff)
}
// encode volatile state
if err := gob.NewEncoder(w).Encode(state); err != nil {
return err
}
// write header
if _, err := w.Seek(0, io.SeekStart); err != nil {
return err
}
_, err := w.Write(offset)
return err
}
func (b *multiBackend) Destroy(id fst.ID) error {
b.lock.Lock()
defer b.lock.Unlock()

View File

@ -1,11 +1,15 @@
package state
import (
"errors"
"io"
"time"
"git.gensokyo.uk/security/fortify/fst"
)
var ErrNoConfig = errors.New("state does not contain config")
type Entries map[fst.ID]*State
type Store interface {
@ -24,13 +28,13 @@ type Store interface {
// Cursor provides access to the store
type Cursor interface {
Save(state *State) error
Save(state *State, configWriter io.WriterTo) error
Destroy(id fst.ID) error
Load() (Entries, error)
Len() (int, error)
}
// State is the on-disk format for a fortified process's state information
// State is a fortify process's state
type State struct {
// fortify instance id
ID fst.ID `json:"instance"`
@ -40,5 +44,5 @@ type State struct {
Config *fst.Config `json:"config"`
// process start time
Time time.Time
Time time.Time `json:"time"`
}

View File

@ -1,6 +1,9 @@
package state_test
import (
"bytes"
"encoding/gob"
"io"
"math/rand/v2"
"reflect"
"slices"
@ -28,9 +31,12 @@ func testStore(t *testing.T, s state.Store) {
tl
)
var tc [tl]state.State
var tc [tl]struct {
state state.State
ct bytes.Buffer
}
for i := 0; i < tl; i++ {
makeState(t, &tc[i])
makeState(t, &tc[i].state, &tc[i].ct)
}
do := func(aid int, f func(c state.Cursor)) {
@ -41,7 +47,7 @@ func testStore(t *testing.T, s state.Store) {
insert := func(i, aid int) {
do(aid, func(c state.Cursor) {
if err := c.Save(&tc[i]); err != nil {
if err := c.Save(&tc[i].state, &tc[i].ct); err != nil {
t.Fatalf("Save(&tc[%v]): error = %v", i, err)
}
})
@ -51,15 +57,17 @@ func testStore(t *testing.T, s state.Store) {
do(aid, func(c state.Cursor) {
if entries, err := c.Load(); err != nil {
t.Fatalf("Load: error = %v", err)
} else if got, ok := entries[tc[i].ID]; !ok {
} else if got, ok := entries[tc[i].state.ID]; !ok {
t.Fatalf("Load: entry %s missing",
&tc[i].ID)
&tc[i].state.ID)
} else {
got.Time = tc[i].Time
if !reflect.DeepEqual(got, &tc[i]) {
got.Time = tc[i].state.Time
tc[i].state.Config = fst.Template()
if !reflect.DeepEqual(got, &tc[i].state) {
t.Fatalf("Load: entry %s got %#v, want %#v",
&tc[i].ID, got, &tc[i])
&tc[i].state.ID, got, &tc[i].state)
}
tc[i].state.Config = nil
}
})
}
@ -104,7 +112,7 @@ func testStore(t *testing.T, s state.Store) {
t.Run("clear aid 1", func(t *testing.T) {
do(1, func(c state.Cursor) {
if err := c.Destroy(tc[insertEntryOtherApp].ID); err != nil {
if err := c.Destroy(tc[insertEntryOtherApp].state.ID); err != nil {
t.Fatalf("Destroy: error = %v", err)
}
})
@ -124,11 +132,13 @@ func testStore(t *testing.T, s state.Store) {
})
}
func makeState(t *testing.T, s *state.State) {
func makeState(t *testing.T, s *state.State, ct io.Writer) {
if err := fst.NewAppID(&s.ID); err != nil {
t.Fatalf("cannot create dummy state: %v", err)
}
s.Config = fst.Template()
if err := gob.NewEncoder(ct).Encode(fst.Template()); err != nil {
t.Fatalf("cannot encode dummy config: %v", err)
}
s.PID = rand.Int()
s.Time = time.Now()
}

View File

@ -93,13 +93,13 @@ func (d *DBus) apply(_ *I) error {
ready := make(chan error, 1)
// background dbus proxy start
if err := d.proxy.Start(ready, d.out, true); err != nil {
if err := d.proxy.Start(ready, d.out, true, true); err != nil {
return fmsg.WrapErrorSuffix(err,
"cannot start message bus 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.Bwrap())
fmsg.VPrintln("message bus proxy bwrap args:", d.proxy.BwrapStatic())
}
// background wait for proxy instance and notify completion

View File

@ -16,13 +16,17 @@ func Exec(p string) ([]*Entry, error) {
cmd *exec.Cmd
)
if b, err := helper.NewBwrap((&bwrap.Config{
Hostname: "fortify-ldd",
Chdir: "/",
NewSession: true,
DieWithParent: true,
}).Bind("/", "/").DevTmpfs("/dev"),
nil, "ldd", func(_, _ int) []string { return []string{p} }); err != nil {
if b, err := helper.NewBwrap(
(&bwrap.Config{
Hostname: "fortify-ldd",
Chdir: "/",
Syscall: &bwrap.SyscallPolicy{DenyDevel: true, Multiarch: true},
NewSession: true,
DieWithParent: true,
}).Bind("/", "/").DevTmpfs("/dev"), "ldd",
nil, func(_, _ int) []string { return []string{p} },
nil, nil,
); err != nil {
return nil, err
} else {
cmd = b.Unwrap()

View File

@ -16,6 +16,7 @@ import (
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/app"
"git.gensokyo.uk/security/fortify/internal/fmsg"
@ -308,6 +309,10 @@ func runApp(config *fst.Config) {
rs := new(app.RunState)
ctx, cancel := context.WithCancel(context.Background())
if fmsg.Verbose() {
bwrap.CPrintln = fmsg.Println
}
// handle signals for graceful shutdown
sig := make(chan os.Signal, 2)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)

104
nixos.nix
View File

@ -7,6 +7,7 @@
let
inherit (lib)
mkMerge
mkIf
mkDefault
mapAttrs
@ -19,6 +20,10 @@ let
;
cfg = config.environment.fortify;
getsubuid = fid: aid: 1000000 + fid * 10000 + aid;
getsubname = fid: aid: "u${toString fid}_a${toString aid}";
getsubhome = fid: aid: "${cfg.stateDir}/u${toString fid}/a${toString aid}";
in
{
@ -33,23 +38,12 @@ in
group = "root";
};
environment.etc = {
fsurc = {
mode = "0400";
text = foldlAttrs (
acc: username: fid:
"${toString config.users.users.${username}.uid} ${toString fid}\n" + acc
) "" cfg.users;
};
userdb.source = pkgs.runCommand "fortify-userdb" { } ''
${cfg.package}/libexec/fuserdb -o $out ${
foldlAttrs (
acc: username: fid:
acc + " ${username}:${toString fid}"
) "-s /run/current-system/sw/bin/nologin -d ${cfg.stateDir}" cfg.users
}
'';
environment.etc.fsurc = {
mode = "0400";
text = foldlAttrs (
acc: username: fid:
"${toString config.users.users.${username}.uid} ${toString fid}\n" + acc
) "" cfg.users;
};
systemd.services.nix-daemon.unitConfig.RequiresMountsFor = [ "/etc/userdb" ];
@ -114,8 +108,8 @@ in
confinement = {
app_id = aid;
inherit (app) groups;
username = "u${toString fid}_a${toString aid}";
home = "${cfg.stateDir}/u${toString fid}/a${toString aid}";
username = getsubname fid aid;
home = getsubhome fid aid;
sandbox = {
inherit (app)
userns
@ -123,6 +117,9 @@ in
dev
env
;
syscall = {
inherit (app) devel multiarch bluetooth;
};
map_real_uid = app.mapRealUid;
no_new_session = app.tty;
filesystem =
@ -173,7 +170,9 @@ in
};
in
pkgs.writeShellScriptBin app.name ''
exec fortify app ${pkgs.writeText "fortify-${app.name}.json" (builtins.toJSON conf)} $@
exec fortify${
if app.verbose then " -v" else ""
} app ${pkgs.writeText "fortify-${app.name}.json" (builtins.toJSON conf)} $@
''
) cfg.apps;
in
@ -205,16 +204,63 @@ in
users = foldlAttrs (
acc: _: fid:
mergeAttrsList (
# aid 0 is reserved
imap1 (aid: app: {
"u${toString fid}_a${toString aid}" = app.extraConfig // {
home.packages = app.packages;
};
}) cfg.apps
)
// acc
mkMerge [
(mergeAttrsList (
# aid 0 is reserved
imap1 (aid: app: {
${getsubname fid aid} = mkMerge [
(cfg.home-manager (getsubname fid aid) (getsubuid fid aid))
app.extraConfig
{ home.packages = app.packages; }
];
}) cfg.apps
))
{ ${getsubname fid 0} = cfg.home-manager (getsubname fid 0) (getsubuid fid 0); }
acc
]
) privPackages cfg.users;
};
users =
let
getuser = fid: aid: {
isSystemUser = true;
createHome = true;
description = "Fortify subordinate user ${toString aid} (u${toString fid})";
group = getsubname fid aid;
home = getsubhome fid aid;
uid = getsubuid fid aid;
};
getgroup = fid: aid: { gid = getsubuid fid aid; };
in
{
users = foldlAttrs (
acc: _: fid:
mkMerge [
(mergeAttrsList (
# aid 0 is reserved
imap1 (aid: _: {
${getsubname fid aid} = getuser fid aid;
}) cfg.apps
))
{ ${getsubname fid 0} = getuser fid 0; }
acc
]
) { } cfg.users;
groups = foldlAttrs (
acc: _: fid:
mkMerge [
(mergeAttrsList (
# aid 0 is reserved
imap1 (aid: _: {
${getsubname fid aid} = getgroup fid aid;
}) cfg.apps
))
{ ${getsubname fid 0} = getgroup fid 0; }
acc
]
) { } cfg.users;
};
};
}

View File

@ -36,7 +36,7 @@ package
*Default:*
` <derivation fortify-0.2.10> `
` <derivation fortify-0.2.11> `
@ -77,6 +77,30 @@ list of package
## environment\.fortify\.apps\.\*\.bluetooth
Whether to enable AF_BLUETOOTH socket operations\.
*Type:*
boolean
*Default:*
` false `
*Example:*
` true `
## environment\.fortify\.apps\.\*\.capability\.dbus
@ -218,7 +242,31 @@ null or anything
Whether to enable access to all devices within the sandbox\.
Whether to enable access to all devices\.
*Type:*
boolean
*Default:*
` false `
*Example:*
` true `
## environment\.fortify\.apps\.\*\.devel
Whether to enable development kernel APIs\.
@ -357,7 +405,31 @@ null or string
Whether to enable mapping to fortifys real UID within the sandbox\.
Whether to enable mapping to priv-user uid\.
*Type:*
boolean
*Default:*
` false `
*Example:*
` true `
## environment\.fortify\.apps\.\*\.multiarch
Whether to enable multiarch kernel support\.
@ -395,7 +467,7 @@ string
Whether to enable network access within the sandbox\.
Whether to enable network access\.
@ -419,7 +491,7 @@ boolean
Whether to enable nix daemon access within the sandbox\.
Whether to enable nix daemon\.
@ -482,7 +554,7 @@ null or package
Whether to enable allow access to the controlling terminal\.
Whether to enable access to the controlling terminal\.
@ -506,7 +578,7 @@ boolean
Whether to enable userns within the sandbox\.
Whether to enable user namespace\.
@ -526,6 +598,44 @@ boolean
## environment\.fortify\.apps\.\*\.verbose
Whether to enable launchers with verbose output\.
*Type:*
boolean
*Default:*
` false `
*Example:*
` true `
## environment\.fortify\.home-manager
Target user shared home-manager configuration\.
*Type:*
function that evaluates to a(n) function that evaluates to a(n) attribute set of anything
## environment\.fortify\.stateDir

View File

@ -26,6 +26,17 @@ in
'';
};
home-manager = mkOption {
type =
let
inherit (types) functionTo attrsOf anything;
in
functionTo (functionTo (attrsOf anything));
description = ''
Target user shared home-manager configuration.
'';
};
apps = mkOption {
type =
let
@ -50,6 +61,8 @@ in
'';
};
verbose = mkEnableOption "launchers with verbose output";
id = mkOption {
type = nullOr str;
default = null;
@ -128,16 +141,20 @@ in
'';
};
nix = mkEnableOption "nix daemon access within the sandbox";
userns = mkEnableOption "userns within the sandbox";
mapRealUid = mkEnableOption "mapping to fortify's real UID within the sandbox";
dev = mkEnableOption "access to all devices within the sandbox";
tty = mkEnableOption "allow access to the controlling terminal";
nix = mkEnableOption "nix daemon";
userns = mkEnableOption "user namespace";
mapRealUid = mkEnableOption "mapping to priv-user uid";
dev = mkEnableOption "access to all devices";
tty = mkEnableOption "access to the controlling terminal";
net = mkEnableOption "network access within the sandbox" // {
net = mkEnableOption "network access" // {
default = true;
};
devel = mkEnableOption "development kernel APIs";
multiarch = mkEnableOption "multiarch kernel support";
bluetooth = mkEnableOption "AF_BLUETOOTH socket operations";
gpu = mkOption {
type = nullOr bool;
default = null;

View File

@ -6,6 +6,7 @@
bubblewrap,
pkg-config,
libffi,
libseccomp,
acl,
wayland,
wayland-protocols,
@ -15,7 +16,7 @@
buildGoModule rec {
pname = "fortify";
version = "0.2.10";
version = "0.2.11";
src = builtins.path {
name = "fortify-src";
@ -45,6 +46,7 @@ buildGoModule rec {
buildInputs =
[
libffi
libseccomp
acl
wayland
wayland-protocols

View File

@ -53,6 +53,10 @@ func printShowInstance(instance *state.State, config *fst.Config, short bool) {
now := time.Now().UTC()
w := tabwriter.NewWriter(direct.Stdout, 0, 1, 4, ' ', 0)
if config.Confinement.Sandbox == nil {
fmt.Print("Warning: this configuration uses permissive defaults!\n\n")
}
if instance != nil {
fmt.Fprintf(w, "State\n")
fmt.Fprintf(w, " Instance:\t%s (%d)\n", instance.ID.String(), instance.PID)
@ -106,9 +110,6 @@ func printShowInstance(instance *state.State, config *fst.Config, short bool) {
// Env map[string]string `json:"env"`
// Link [][2]string `json:"symlink"`
} else {
// this gets printed before everything else
fmt.Println("WARNING: current configuration uses permissive defaults!")
}
fmt.Fprintf(w, " Command:\t%s\n", strings.Join(config.Command, " "))
fmt.Fprintf(w, "\n")

View File

@ -44,7 +44,6 @@ nixosTest {
# For glinfo and wayland-info:
mesa-demos
wayland-utils
alacritty
# For D-Bus tests:
libnotify
@ -83,7 +82,7 @@ nixosTest {
sed s/Mod4/Mod1/ /etc/sway/config > ~/.config/sway/config
sway --validate
sway && touch /tmp/sway-exit-ok
systemd-cat --identifier=sway sway && touch /tmp/sway-exit-ok
fi
'';
@ -111,6 +110,43 @@ nixosTest {
enable = true;
stateDir = "/var/lib/fortify";
users.alice = 0;
home-manager = _: _: { home.stateVersion = "23.05"; };
apps = [
{
name = "ne-foot";
verbose = true;
share = pkgs.foot;
packages = [ pkgs.foot ];
command = "foot";
capability = {
dbus = false;
pulse = false;
};
}
{
name = "pa-foot";
verbose = true;
share = pkgs.foot;
packages = [ pkgs.foot ];
command = "foot";
capability.dbus = false;
}
{
name = "x11-alacritty";
verbose = true;
share = pkgs.alacritty;
packages = [ pkgs.alacritty ];
command = "alacritty";
capability = {
wayland = false;
x11 = true;
dbus = false;
pulse = false;
};
}
];
};
imports = [
@ -176,16 +212,18 @@ nixosTest {
machine.screenshot(name)
def check_state(command, enablements):
def check_state(name, enablements):
instances = json.loads(machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 fortify --json ps"))
if len(instances) != 1:
raise Exception(f"unexpected state length {len(instances)}")
instance = next(iter(instances.values()))
if instance['config']['command'] != command:
config = instance['config']
if len(config['command']) != 1 or not(config['command'][0].startswith("/nix/store/")) or not(config['command'][0].endswith(f"{name}-start")):
raise Exception(f"unexpected command {instance['config']['command']}")
if instance['config']['confinement']['enablements'] != enablements:
if config['confinement']['enablements'] != enablements:
raise Exception(f"unexpected enablements {instance['config']['confinement']['enablements']}")
@ -212,60 +250,60 @@ nixosTest {
# Create fortify uid 0 state directory:
machine.succeed("install -dm 0755 -o u0_a0 -g users /var/lib/fortify/u0")
# Start fortify 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"))
machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-bare")
# Start fortify within Wayland session:
# Start fortify permissive defaults within Wayland session:
fortify('-v run --wayland --dbus notify-send -a "NixOS Tests" "Test notification" "Notification from within sandbox." && touch /tmp/dbus-done')
machine.wait_for_file("/tmp/dbus-done")
collect_state_ui("dbus_notify_exited")
machine.succeed("pkill -9 mako")
# Start a terminal (foot) within fortify:
fortify("run --wayland foot")
wait_for_window("u0_a0@machine")
# Start app (foot) with Wayland enablement:
swaymsg("exec ne-foot")
wait_for_window("u0_a1@machine")
machine.send_chars("clear; wayland-info && touch /tmp/success-client\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-client")
collect_state_ui("foot_wayland_permissive")
check_state(["foot"], 1)
machine.wait_for_file("/tmp/fortify.1000/tmpdir/1/success-client")
collect_state_ui("foot_wayland")
check_state("ne-foot", 1)
# Verify acl on XDG_RUNTIME_DIR:
print(machine.succeed("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000000"))
print(machine.succeed("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000001"))
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep foot")
# Verify acl cleanup on XDG_RUNTIME_DIR:
machine.wait_until_fails("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000000")
machine.wait_until_fails("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000001")
# Start a terminal (foot) within fortify from a terminal:
swaymsg("exec foot $SHELL -c '(fortify run --wayland foot) & sleep 1 && fortify show --short $(fortify ps --short) && touch /tmp/ps-show-ok && cat'")
wait_for_window("u0_a0@machine")
# Start app (foot) with Wayland enablement from a terminal:
swaymsg("exec foot $SHELL -c '(ne-foot) & sleep 1 && fortify show $(fortify ps --short) && touch /tmp/ps-show-ok && cat'")
wait_for_window("u0_a1@machine")
machine.send_chars("clear; wayland-info && touch /tmp/success-client-term\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-client-term")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/1/success-client-term")
machine.wait_for_file("/tmp/ps-show-ok")
collect_state_ui("foot_wayland_permissive_term")
check_state(["foot"], 1)
collect_state_ui("foot_wayland_term")
check_state("ne-foot", 1)
machine.send_chars("exit\n")
wait_for_window("foot")
machine.send_key("ctrl-c")
machine.wait_until_fails("pgrep foot")
# Test PulseAudio (fortify does not support PipeWire yet):
fortify("run --wayland --pulse foot")
wait_for_window("u0_a0@machine")
swaymsg("exec pa-foot")
wait_for_window("u0_a2@machine")
machine.send_chars("clear; pactl info && touch /tmp/success-pulse\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-pulse")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/2/success-pulse")
collect_state_ui("pulse_wayland")
check_state(["foot"], 9)
check_state("pa-foot", 9)
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep foot")
# Test XWayland (foot does not support X):
fortify("run -X alacritty")
wait_for_window("u0_a0@machine")
swaymsg("exec x11-alacritty")
wait_for_window("u0_a3@machine")
machine.send_chars("clear; glinfo && touch /tmp/success-client-x11\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-client-x11")
collect_state_ui("alacritty_x11_permissive")
check_state(["alacritty"], 2)
machine.wait_for_file("/tmp/fortify.1000/tmpdir/3/success-client-x11")
collect_state_ui("alacritty_x11")
check_state("x11-alacritty", 2)
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep alacritty")