Compare commits

..

6 Commits

Author SHA1 Message Date
e2489059c1
helper/bwrap: implement overlayfs builder
All checks were successful
Tests / Go tests (push) Successful in 33s
Nix / NixOS tests (push) Successful in 4m5s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-05 20:09:35 +09:00
2e3f6a4c51
helper/bwrap: move test out of bwrap package
All checks were successful
Tests / Go tests (push) Successful in 36s
Nix / NixOS tests (push) Successful in 4m51s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-05 19:45:24 +09:00
2162029f46
helper/bwrap: add json struct tag to filesystem
All checks were successful
Tests / Go tests (push) Successful in 38s
Nix / NixOS tests (push) Successful in 4m43s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-05 19:41:04 +09:00
a1148edd00
fst/config: allocate filesystem slice
All checks were successful
Tests / Go tests (push) Successful in 32s
Nix / NixOS tests (push) Successful in 4m5s
Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-04 00:16:41 +09:00
6acd0d4e88
linux/std: handle fsu exit status 1
All checks were successful
Tests / Go tests (push) Successful in 34s
Nix / NixOS tests (push) Successful in 2m27s
Printing "exit status 1" is confusing. This handles the ExitError and returns EACCES instead.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-01 21:34:57 +09:00
35b7142317
fortify: show system info when instance is not specified
All checks were successful
Tests / Go tests (push) Successful in 38s
Nix / NixOS tests (push) Successful in 4m32s
This contains useful information not obtainable by external tools.

Signed-off-by: Ophestra <cat@gensokyo.uk>
2025-01-01 19:35:50 +09:00
11 changed files with 223 additions and 48 deletions

View File

@ -141,6 +141,12 @@ func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
Hostname: s.Hostname, Hostname: s.Hostname,
Clearenv: true, Clearenv: true,
SetEnv: s.Env, SetEnv: s.Env,
/* this is only 4 KiB of memory on a 64-bit system,
permissive defaults on NixOS results in around 100 entries
so this capacity should eliminate copies for most setups */
Filesystem: make([]bwrap.FSBuilder, 0, 256),
NewSession: !s.NoNewSession, NewSession: !s.NoNewSession,
DieWithParent: true, DieWithParent: true,
AsInit: true, AsInit: true,

5
fst/info.go Normal file
View File

@ -0,0 +1,5 @@
package fst
type Info struct {
User int `json:"user"`
}

View File

@ -4,10 +4,20 @@ const (
Tmpfs = iota Tmpfs = iota
Dir Dir
Symlink Symlink
OverlaySrc
Overlay
TmpOverlay
ROOverlay
) )
var awkwardArgs = [...]string{ var awkwardArgs = [...]string{
Tmpfs: "--tmpfs", Tmpfs: "--tmpfs",
Dir: "--dir", Dir: "--dir",
Symlink: "--symlink", Symlink: "--symlink",
OverlaySrc: "--overlay-src",
Overlay: "--overlay",
TmpOverlay: "--tmp-overlay",
ROOverlay: "--ro-overlay",
} }

View File

@ -52,7 +52,7 @@ type Config struct {
LockFile []string `json:"lock_file,omitempty"` LockFile []string `json:"lock_file,omitempty"`
// ordered filesystem args // ordered filesystem args
Filesystem []FSBuilder Filesystem []FSBuilder `json:"filesystem,omitempty"`
// change permissions (must already exist) // change permissions (must already exist)
// (--chmod OCTAL PATH) // (--chmod OCTAL PATH)
@ -78,6 +78,8 @@ type Config struct {
--userns FD Use this user namespace (cannot combine with --unshare-user) --userns FD Use this user namespace (cannot combine with --unshare-user)
--userns2 FD After setup switch to this user namespace, only useful with --userns --userns2 FD After setup switch to this user namespace, only useful with --userns
--pidns FD Use this pid namespace (as parent namespace if using --unshare-pid) --pidns FD Use this pid namespace (as parent namespace if using --unshare-pid)
--bind-fd FD DEST Bind open directory or path fd on DEST
--ro-bind-fd FD DEST Bind open directory or path fd read-only on DEST
--exec-label LABEL Exec label for the sandbox --exec-label LABEL Exec label for the sandbox
--file-label LABEL File label for temporary sandbox content --file-label LABEL File label for temporary sandbox content
--file FD DEST Copy from FD to destination DEST --file FD DEST Copy from FD to destination DEST
@ -178,6 +180,74 @@ func (t *TmpfsConfig) Append(args *[]string) {
*args = append(*args, awkwardArgs[Tmpfs], t.Dir) *args = append(*args, awkwardArgs[Tmpfs], t.Dir)
} }
type OverlayConfig struct {
/*
read files from SRC in the following overlay
(--overlay-src SRC)
*/
Src []string `json:"src,omitempty"`
/*
mount overlayfs on DEST, with RWSRC as the host path for writes and
WORKDIR an empty directory on the same filesystem as RWSRC
(--overlay RWSRC WORKDIR DEST)
if nil, mount overlayfs on DEST, with writes going to an invisible tmpfs
(--tmp-overlay DEST)
if either strings are empty, mount overlayfs read-only on DEST
(--ro-overlay DEST)
*/
Persist *[2]string `json:"persist,omitempty"`
/*
--overlay RWSRC WORKDIR DEST
--tmp-overlay DEST
--ro-overlay DEST
*/
Dest string `json:"dest"`
}
func (o *OverlayConfig) Path() string {
return o.Dest
}
func (o *OverlayConfig) Len() int {
// (--tmp-overlay DEST) or (--ro-overlay DEST)
p := 2
// (--overlay RWSRC WORKDIR DEST)
if o.Persist != nil && o.Persist[0] != "" && o.Persist[1] != "" {
p = 4
}
return p + len(o.Src)*2
}
func (o *OverlayConfig) Append(args *[]string) {
// --overlay-src SRC
for _, src := range o.Src {
*args = append(*args, awkwardArgs[OverlaySrc], src)
}
if o.Persist != nil {
if o.Persist[0] != "" && o.Persist[1] != "" {
// --overlay RWSRC WORKDIR
*args = append(*args, awkwardArgs[Overlay], o.Persist[0], o.Persist[1])
} else {
// --ro-overlay
*args = append(*args, awkwardArgs[ROOverlay])
}
} else {
// --tmp-overlay
*args = append(*args, awkwardArgs[TmpOverlay])
}
// DEST
*args = append(*args, o.Dest)
}
type SymlinkConfig [2]string type SymlinkConfig [2]string
func (s SymlinkConfig) Path() string { func (s SymlinkConfig) Path() string {

View File

@ -96,6 +96,31 @@ func (c *Config) Tmpfs(dest string, size int, perm ...os.FileMode) *Config {
return c return c
} }
// Overlay mount overlayfs on DEST, with writes going to an invisible tmpfs
// (--tmp-overlay DEST)
func (c *Config) Overlay(dest string, src ...string) *Config {
c.Filesystem = append(c.Filesystem, &OverlayConfig{Src: src, Dest: dest})
return c
}
// Join mount overlayfs read-only on DEST
// (--ro-overlay DEST)
func (c *Config) Join(dest string, src ...string) *Config {
c.Filesystem = append(c.Filesystem, &OverlayConfig{Src: src, Dest: dest, Persist: new([2]string)})
return c
}
// Persist mount overlayfs on DEST, with RWSRC as the host path for writes and
// WORKDIR an empty directory on the same filesystem as RWSRC
// (--overlay RWSRC WORKDIR DEST)
func (c *Config) Persist(dest, rwsrc, workdir string, src ...string) *Config {
if rwsrc == "" || workdir == "" {
panic("persist called without required paths")
}
c.Filesystem = append(c.Filesystem, &OverlayConfig{Src: src, Dest: dest, Persist: &[2]string{rwsrc, workdir}})
return c
}
// Mqueue mount new mqueue in sandbox // Mqueue mount new mqueue in sandbox
// (--mqueue DEST) // (--mqueue DEST)
func (c *Config) Mqueue(dest string) *Config { func (c *Config) Mqueue(dest string) *Config {

View File

@ -1,19 +1,40 @@
package bwrap package bwrap_test
import ( import (
"slices" "slices"
"testing" "testing"
"git.gensokyo.uk/security/fortify/helper/bwrap"
) )
func TestConfig_Args(t *testing.T) { func TestConfig_Args(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
conf *Config conf *bwrap.Config
want []string want []string
}{ }{
{
name: "overlayfs",
conf: (new(bwrap.Config)).
Overlay("/etc", "/etc").
Join("/.fortify/bin", "/bin", "/usr/bin", "/usr/local/bin").
Persist("/nix", "/data/data/org.chromium.Chromium/overlay/rwsrc", "/data/data/org.chromium.Chromium/workdir", "/data/app/org.chromium.Chromium/nix"),
want: []string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// Overlay("/etc", "/etc")
"--overlay-src", "/etc", "--tmp-overlay", "/etc",
// Join("/.fortify/bin", "/bin", "/usr/bin", "/usr/local/bin")
"--overlay-src", "/bin", "--overlay-src", "/usr/bin",
"--overlay-src", "/usr/local/bin", "--ro-overlay", "/.fortify/bin",
// Persist("/nix", "/data/data/org.chromium.Chromium/overlay/rwsrc", "/data/data/org.chromium.Chromium/workdir", "/data/app/org.chromium.Chromium/nix")
"--overlay-src", "/data/app/org.chromium.Chromium/nix",
"--overlay", "/data/data/org.chromium.Chromium/overlay/rwsrc", "/data/data/org.chromium.Chromium/workdir", "/nix",
},
},
{ {
name: "xdg-dbus-proxy constraint sample", name: "xdg-dbus-proxy constraint sample",
conf: (&Config{ conf: (&bwrap.Config{
Unshare: nil, Unshare: nil,
UserNS: false, UserNS: false,
Clearenv: true, Clearenv: true,
@ -71,7 +92,7 @@ func TestConfig_Args(t *testing.T) {
}, },
{ {
name: "fortify permissive default nixos", name: "fortify permissive default nixos",
conf: (&Config{ conf: (&bwrap.Config{
Unshare: nil, Unshare: nil,
Net: true, Net: true,
UserNS: true, UserNS: true,

View File

@ -1,6 +1,7 @@
package linux package linux
import ( import (
"errors"
"io" "io"
"io/fs" "io/fs"
"os" "os"
@ -8,6 +9,7 @@ import (
"os/user" "os/user"
"strconv" "strconv"
"sync" "sync"
"syscall"
"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"
@ -79,9 +81,15 @@ func (s *Std) Uid(aid int) (int, error) {
cmd.Stderr = os.Stderr // pass through fatal messages cmd.Stderr = os.Stderr // pass through fatal messages
cmd.Env = []string{"FORTIFY_APP_ID=" + strconv.Itoa(aid)} cmd.Env = []string{"FORTIFY_APP_ID=" + strconv.Itoa(aid)}
cmd.Dir = "/" cmd.Dir = "/"
var p []byte var (
p []byte
exitError *exec.ExitError
)
if p, u.err = cmd.Output(); u.err == nil { if p, u.err = cmd.Output(); u.err == nil {
u.uid, u.err = strconv.Atoi(string(p)) u.uid, u.err = strconv.Atoi(string(p))
} else if errors.As(u.err, &exitError) && exitError != nil && exitError.ExitCode() == 1 {
u.err = syscall.EACCES
} }
return u.uid, u.err return u.uid, u.err
} }

34
main.go
View File

@ -4,6 +4,7 @@ import (
_ "embed" _ "embed"
"flag" "flag"
"fmt" "fmt"
"os"
"os/user" "os/user"
"strconv" "strconv"
"strings" "strings"
@ -16,7 +17,6 @@ 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"
"git.gensokyo.uk/security/fortify/internal/state"
"git.gensokyo.uk/security/fortify/internal/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
@ -33,7 +33,7 @@ func init() {
flag.BoolVar(&flagJSON, "json", false, "Format output in JSON when applicable") flag.BoolVar(&flagJSON, "json", false, "Format output in JSON when applicable")
} }
var os = new(linux.Std) var sys linux.System = new(linux.Std)
type gl []string type gl []string
@ -65,7 +65,7 @@ func main() {
fmt.Println("Usage:\tfortify [-v] [--json] COMMAND [OPTIONS]") fmt.Println("Usage:\tfortify [-v] [--json] COMMAND [OPTIONS]")
fmt.Println() fmt.Println()
fmt.Println("Commands:") fmt.Println("Commands:")
w := tabwriter.NewWriter(os.Stdout(), 0, 1, 4, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 1, 4, ' ', 0)
commands := [][2]string{ commands := [][2]string{
{"app", "Launch app defined by the specified config file"}, {"app", "Launch app defined by the specified config file"},
{"run", "Configure and start a permissive default sandbox"}, {"run", "Configure and start a permissive default sandbox"},
@ -128,24 +128,20 @@ func main() {
// Ignore errors; set is set for ExitOnError. // Ignore errors; set is set for ExitOnError.
_ = set.Parse(args[1:]) _ = set.Parse(args[1:])
var ( switch len(set.Args()) {
config *fst.Config case 0: // system
instance *state.State printShowSystem(short)
name string case 1: // instance
) name := set.Args()[0]
config, instance := tryShort(name)
if len(set.Args()) != 1 {
fmsg.Fatal("show requires 1 argument")
} else {
name = set.Args()[0]
config, instance = tryShort(name)
}
if config == nil { if config == nil {
config = tryPath(name) config = tryPath(name)
} }
printShowInstance(instance, config, short)
default:
fmsg.Fatal("show requires 1 argument")
}
printShow(instance, config, short)
fmsg.Exit(0) fmsg.Exit(0)
case "app": // launch app from configuration file case "app": // launch app from configuration file
if len(args) < 2 { if len(args) < 2 {
@ -211,7 +207,7 @@ func main() {
passwdOnce sync.Once passwdOnce sync.Once
passwdFunc = func() { passwdFunc = func() {
var us string var us string
if uid, err := os.Uid(aid); err != nil { if uid, err := sys.Uid(aid); err != nil {
fmsg.Fatalf("cannot obtain uid from fsu: %v", err) fmsg.Fatalf("cannot obtain uid from fsu: %v", err)
} else { } else {
us = strconv.Itoa(uid) us = strconv.Itoa(uid)
@ -292,7 +288,7 @@ func main() {
} }
func runApp(config *fst.Config) { func runApp(config *fst.Config) {
a, err := app.New(os) a, err := app.New(sys)
if err != nil { if 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

@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
direct "os" "os"
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
@ -38,7 +38,7 @@ func tryPath(name string) (config *fst.Config) {
}() }()
} }
} else { } else {
r = direct.Stdin r = os.Stdin
} }
if err := json.NewDecoder(r).Decode(&config); err != nil { if err := json.NewDecoder(r).Decode(&config); err != nil {
@ -61,7 +61,7 @@ func tryFd(name string) io.ReadCloser {
} }
fmsg.Fatalf("cannot get fd %d: %v", fd, errno) fmsg.Fatalf("cannot get fd %d: %v", fd, errno)
} }
return direct.NewFile(fd, strconv.Itoa(v)) return os.NewFile(fd, strconv.Itoa(v))
} }
} }
@ -85,7 +85,7 @@ func tryShort(name string) (config *fst.Config, instance *state.State) {
if likePrefix && len(name) >= 8 { if likePrefix && len(name) >= 8 {
fmsg.VPrintln("argument looks like prefix") fmsg.VPrintln("argument looks like prefix")
s := state.NewMulti(os.Paths().RunDirPath) s := state.NewMulti(sys.Paths().RunDirPath)
if entries, err := state.Join(s); err != nil { if entries, err := state.Join(s); err != nil {
fmsg.Printf("cannot join store: %v", err) fmsg.Printf("cannot join store: %v", err)
// drop to fetch from file // drop to fetch from file

View File

@ -16,14 +16,37 @@ import (
"git.gensokyo.uk/security/fortify/internal/state" "git.gensokyo.uk/security/fortify/internal/state"
) )
func printShow(instance *state.State, config *fst.Config, short bool) { func printShowSystem(short bool) {
if flagJSON { info := new(fst.Info)
v := any(config)
if instance != nil { // get fid by querying uid of aid 0
v = instance if uid, err := sys.Uid(0); err != nil {
fmsg.Fatalf("cannot obtain uid from fsu: %v", err)
} else {
info.User = (uid / 10000) - 100
} }
printJSON(v) if flagJSON {
printJSON(info)
return
}
w := tabwriter.NewWriter(direct.Stdout, 0, 1, 4, ' ', 0)
fmt.Fprintf(w, "User:\t%d\n", info.User)
if err := w.Flush(); err != nil {
fmsg.Fatalf("cannot flush tabwriter: %v", err)
}
}
func printShowInstance(instance *state.State, config *fst.Config, short bool) {
if flagJSON {
if instance != nil {
printJSON(instance)
} else {
printJSON(config)
}
return return
} }
@ -171,7 +194,7 @@ func printPs(short bool) {
now := time.Now().UTC() now := time.Now().UTC()
var entries state.Entries var entries state.Entries
s := state.NewMulti(os.Paths().RunDirPath) s := state.NewMulti(sys.Paths().RunDirPath)
if e, err := state.Join(s); err != nil { if e, err := state.Join(s); err != nil {
fmsg.Fatalf("cannot join store: %v", err) fmsg.Fatalf("cannot join store: %v", err)
} else { } else {

View File

@ -19,12 +19,20 @@ nixosTest {
nodes.machine = nodes.machine =
{ lib, pkgs, ... }: { lib, pkgs, ... }:
{ {
users.users.alice = { users.users = {
alice = {
isNormalUser = true; isNormalUser = true;
description = "Alice Foobar"; description = "Alice Foobar";
password = "foobar"; password = "foobar";
uid = 1000; uid = 1000;
}; };
untrusted = {
isNormalUser = true;
description = "Untrusted user";
password = "foobar";
uid = 1001;
};
};
home-manager.users.alice.home.stateVersion = "24.11"; home-manager.users.alice.home.stateVersion = "24.11";
@ -198,6 +206,9 @@ nixosTest {
machine.wait_for_file("/run/user/1000/wayland-1") machine.wait_for_file("/run/user/1000/wayland-1")
machine.wait_for_file("/tmp/sway-ipc.sock") machine.wait_for_file("/tmp/sway-ipc.sock")
# Deny unmapped uid:
print(machine.fail("sudo -u untrusted -i ${self.packages.${system}.fortify}/bin/fortify -v run"))
# Create fortify uid 0 state directory: # Create fortify uid 0 state directory:
machine.succeed("install -dm 0755 -o u0_a0 -g users /var/lib/fortify/u0") machine.succeed("install -dm 0755 -o u0_a0 -g users /var/lib/fortify/u0")