Compare commits

..

No commits in common. "371dd5b938e70fa077d1452456fdd203b7a61cb9" and "71135f339a4384ec67fa87ed6624a09168a7e52b" have entirely different histories.

150 changed files with 4534 additions and 7545 deletions

View File

@ -22,23 +22,6 @@ jobs:
path: result/* path: result/*
retention-days: 1 retention-days: 1
fpkg:
name: Fpkg
runs-on: nix
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run NixOS test
run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.fpkg
- name: Upload test output
uses: actions/upload-artifact@v3
with:
name: "fpkg-vm-output"
path: result/*
retention-days: 1
race: race:
name: Data race detector name: Data race detector
runs-on: nix runs-on: nix
@ -60,7 +43,6 @@ jobs:
name: Flake checks name: Flake checks
needs: needs:
- fortify - fortify
- fpkg
- race - race
runs-on: nix runs-on: nix
steps: steps:

View File

@ -7,9 +7,8 @@
{ {
lib, lib,
stdenv,
closureInfo,
writeScript, writeScript,
writeScriptBin,
runtimeShell, runtimeShell,
writeText, writeText,
symlinkJoin, symlinkJoin,
@ -17,15 +16,12 @@
runCommand, runCommand,
fetchFromGitHub, fetchFromGitHub,
zstd,
nix, nix,
sqlite,
name ? throw "name is required", name ? throw "name is required",
version ? throw "version is required", version ? throw "version is required",
pname ? "${name}-${version}", pname ? "${name}-${version}",
modules ? [ ], modules ? [ ],
nixosModules ? [ ],
script ? '' script ? ''
exec "$SHELL" "$@" exec "$SHELL" "$@"
'', '',
@ -77,8 +73,6 @@ let
etc.nixpkgs.source = nixpkgs.outPath; etc.nixpkgs.source = nixpkgs.outPath;
systemPackages = [ pkgs.nix ]; systemPackages = [ pkgs.nix ];
}; };
imports = nixosModules;
}; };
nixos = nixpkgs.lib.nixosSystem { nixos = nixpkgs.lib.nixosSystem {
inherit system; inherit system;
@ -171,7 +165,11 @@ let
broadcast = { }; broadcast = { };
}); });
enablements = (if allow_wayland then 1 else 0) + (if allow_x11 then 2 else 0) + (if allow_dbus then 4 else 0) + (if allow_pulse then 8 else 0); enablements =
(if allow_wayland then 1 else 0)
+ (if allow_x11 then 2 else 0)
+ (if allow_dbus then 4 else 0)
+ (if allow_pulse then 8 else 0);
mesa = if gpu then mesaWrappers else null; mesa = if gpu then mesaWrappers else null;
nix_gl = if gpu then nixGL else null; nix_gl = if gpu then nixGL else null;
@ -180,73 +178,26 @@ let
}; };
in in
stdenv.mkDerivation { writeScriptBin "build-fpkg-${pname}" ''
name = "${pname}.pkg"; #!${runtimeShell} -el
inherit version; OUT="$(mktemp -d)"
__structuredAttrs = true; TAR="$(mktemp -u)"
set -x
nativeBuildInputs = [ nix copy --no-check-sigs --to "$OUT" "${nix}" "${nixos.config.system.build.toplevel}"
zstd nix store --store "$OUT" optimise
nix chmod -R +r "$OUT/nix/var"
sqlite nix copy --no-check-sigs --to "file://$OUT/res?compression=zstd&compression-level=19&parallel-compression=true" \
]; "${homeManagerConfiguration.activationPackage}" \
"${launcher}" ${if gpu then "${mesaWrappers} ${nixGL}" else ""}
mkdir -p "$OUT/etc"
tar -C "$OUT/etc" -xf "${etc}/etc.tar"
cp "${writeText "bundle.json" info}" "$OUT/bundle.json"
buildCommand = '' # creating an intermediate file improves zstd performance
NIX_ROOT="$(mktemp -d)" tar -C "$OUT" -cf "$TAR" .
export USER="nobody" chmod +w -R "$OUT" && rm -rf "$OUT"
# create bootstrap store zstd -T0 -19 -fo "${pname}.pkg" "$TAR"
bootstrapClosureInfo="${ rm "$TAR"
closureInfo { ''
rootPaths = [
nix
nixos.config.system.build.toplevel
];
}
}"
echo "copying bootstrap store paths..."
mkdir -p "$NIX_ROOT/nix/store"
xargs -n 1 -a "$bootstrapClosureInfo/store-paths" cp -at "$NIX_ROOT/nix/store/"
NIX_REMOTE="local?root=$NIX_ROOT" nix-store --load-db < "$bootstrapClosureInfo/registration"
NIX_REMOTE="local?root=$NIX_ROOT" nix-store --optimise
sqlite3 "$NIX_ROOT/nix/var/nix/db/db.sqlite" "UPDATE ValidPaths SET registrationTime = ''${SOURCE_DATE_EPOCH}"
chmod -R +r "$NIX_ROOT/nix/var"
# create binary cache
closureInfo="${
closureInfo {
rootPaths =
[
homeManagerConfiguration.activationPackage
launcher
]
++ optionals gpu [
mesaWrappers
nixGL
];
}
}"
echo "copying application paths..."
TMP_STORE="$(mktemp -d)"
mkdir -p "$TMP_STORE/nix/store"
xargs -n 1 -a "$closureInfo/store-paths" cp -at "$TMP_STORE/nix/store/"
NIX_REMOTE="local?root=$TMP_STORE" nix-store --load-db < "$closureInfo/registration"
sqlite3 "$TMP_STORE/nix/var/nix/db/db.sqlite" "UPDATE ValidPaths SET registrationTime = ''${SOURCE_DATE_EPOCH}"
NIX_REMOTE="local?root=$TMP_STORE" nix --offline --extra-experimental-features nix-command \
--verbose --log-format raw-with-logs \
copy --all --no-check-sigs --to \
"file://$NIX_ROOT/res?compression=zstd&compression-level=19&parallel-compression=true"
# package /etc
mkdir -p "$NIX_ROOT/etc"
tar -C "$NIX_ROOT/etc" -xf "${etc}/etc.tar"
# write metadata
cp "${writeText "bundle.json" info}" "$NIX_ROOT/bundle.json"
# create an intermediate file to improve zstd performance
INTER="$(mktemp)"
tar -C "$NIX_ROOT" -cf "$INTER" .
zstd -T0 -19 -fo "$out" "$INTER"
'';
}

View File

@ -4,15 +4,12 @@ import (
"encoding/json" "encoding/json"
"log" "log"
"os" "os"
"path"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
"git.gensokyo.uk/security/fortify/system" "git.gensokyo.uk/security/fortify/system"
) )
type appInfo struct { type bundleInfo struct {
Name string `json:"name"` Name string `json:"name"`
Version string `json:"version"` Version string `json:"version"`
@ -23,15 +20,13 @@ type appInfo struct {
// passed through to [fst.Config] // passed through to [fst.Config]
Groups []string `json:"groups,omitempty"` Groups []string `json:"groups,omitempty"`
// passed through to [fst.Config] // passed through to [fst.Config]
Devel bool `json:"devel,omitempty"` UserNS bool `json:"userns,omitempty"`
// passed through to [fst.Config]
Userns bool `json:"userns,omitempty"`
// passed through to [fst.Config] // passed through to [fst.Config]
Net bool `json:"net,omitempty"` Net bool `json:"net,omitempty"`
// passed through to [fst.Config] // passed through to [fst.Config]
Dev bool `json:"dev,omitempty"` Dev bool `json:"dev,omitempty"`
// passed through to [fst.Config] // passed through to [fst.Config]
Tty bool `json:"tty,omitempty"` NoNewSession bool `json:"no_new_session,omitempty"`
// passed through to [fst.Config] // passed through to [fst.Config]
MapRealUID bool `json:"map_real_uid,omitempty"` MapRealUID bool `json:"map_real_uid,omitempty"`
// passed through to [fst.Config] // passed through to [fst.Config]
@ -41,11 +36,13 @@ type appInfo struct {
// passed through to [fst.Config] // passed through to [fst.Config]
SessionBus *dbus.Config `json:"session_bus,omitempty"` SessionBus *dbus.Config `json:"session_bus,omitempty"`
// passed through to [fst.Config] // passed through to [fst.Config]
Enablements system.Enablement `json:"enablements"` Enablements system.Enablements `json:"enablements"`
// passed through to [fst.Config] // passed through inverted to [bwrap.SyscallPolicy]
Devel bool `json:"devel,omitempty"`
// passed through to [bwrap.SyscallPolicy]
Multiarch bool `json:"multiarch,omitempty"` Multiarch bool `json:"multiarch,omitempty"`
// passed through to [fst.Config] // passed through to [bwrap.SyscallPolicy]
Bluetooth bool `json:"bluetooth,omitempty"` Bluetooth bool `json:"bluetooth,omitempty"`
// allow gpu access within sandbox // allow gpu access within sandbox
@ -62,64 +59,8 @@ type appInfo struct {
ActivationPackage string `json:"activation_package"` ActivationPackage string `json:"activation_package"`
} }
func (app *appInfo) toFst(pathSet *appPathSet, argv []string, flagDropShell bool) *fst.Config { func loadBundleInfo(name string, beforeFail func()) *bundleInfo {
config := &fst.Config{ bundle := new(bundleInfo)
ID: app.ID,
Path: argv[0],
Args: argv,
Confinement: fst.ConfinementConfig{
AppID: app.AppID,
Groups: app.Groups,
Username: "fortify",
Inner: path.Join("/data/data", app.ID),
Outer: pathSet.homeDir,
Sandbox: &fst.SandboxConfig{
Hostname: formatHostname(app.Name),
Devel: app.Devel,
Userns: app.Userns,
Net: app.Net,
Dev: app.Dev,
Tty: app.Tty || flagDropShell,
MapRealUID: app.MapRealUID,
DirectWayland: app.DirectWayland,
Filesystem: []*fst.FilesystemConfig{
{Src: path.Join(pathSet.nixPath, "store"), Dst: "/nix/store", Must: true},
{Src: pathSet.metaPath, Dst: path.Join(fst.Tmp, "app"), Must: true},
{Src: "/etc/resolv.conf"},
{Src: "/sys/block"},
{Src: "/sys/bus"},
{Src: "/sys/class"},
{Src: "/sys/dev"},
{Src: "/sys/devices"},
},
Link: [][2]string{
{app.CurrentSystem, "/run/current-system"},
{"/run/current-system/sw/bin", "/bin"},
{"/run/current-system/sw/bin", "/usr/bin"},
},
Etc: path.Join(pathSet.cacheDir, "etc"),
AutoEtc: true,
},
ExtraPerms: []*fst.ExtraPermConfig{
{Path: dataHome, Execute: true},
{Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true},
},
SystemBus: app.SystemBus,
SessionBus: app.SessionBus,
Enablements: app.Enablements,
},
}
if app.Multiarch {
config.Confinement.Sandbox.Seccomp |= seccomp.FlagMultiarch
}
if app.Bluetooth {
config.Confinement.Sandbox.Seccomp |= seccomp.FlagBluetooth
}
return config
}
func loadAppInfo(name string, beforeFail func()) *appInfo {
bundle := new(appInfo)
if f, err := os.Open(name); err != nil { if f, err := os.Open(name); err != nil {
beforeFail() beforeFail()
log.Fatalf("cannot open bundle: %v", err) log.Fatalf("cannot open bundle: %v", err)

191
cmd/fpkg/install.go Normal file
View File

@ -0,0 +1,191 @@
package main
import (
"encoding/json"
"flag"
"log"
"os"
"path"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
func actionInstall(args []string) {
set := flag.NewFlagSet("install", flag.ExitOnError)
var (
dropShellInstall bool
dropShellActivate bool
)
set.BoolVar(&dropShellInstall, "si", false, "Drop to a shell on installation")
set.BoolVar(&dropShellActivate, "sa", false, "Drop to a shell on activation")
// Ignore errors; set is set for ExitOnError.
_ = set.Parse(args)
args = set.Args()
if len(args) != 1 {
log.Fatal("invalid argument")
}
pkgPath := args[0]
if !path.IsAbs(pkgPath) {
if dir, err := os.Getwd(); err != nil {
log.Fatalf("cannot get current directory: %v", err)
} else {
pkgPath = path.Join(dir, pkgPath)
}
}
/*
Look up paths to programs started by fpkg.
This is done here to ease error handling as cleanup is not yet required.
*/
var (
_ = lookPath("zstd")
tar = lookPath("tar")
chmod = lookPath("chmod")
rm = lookPath("rm")
)
/*
Extract package and set up for cleanup.
*/
var workDir string
if p, err := os.MkdirTemp("", "fpkg.*"); err != nil {
log.Fatalf("cannot create temporary directory: %v", err)
} else {
workDir = p
}
cleanup := func() {
// should be faster than a native implementation
mustRun(chmod, "-R", "+w", workDir)
mustRun(rm, "-rf", workDir)
}
beforeRunFail.Store(&cleanup)
mustRun(tar, "-C", workDir, "-xf", pkgPath)
/*
Parse bundle and app metadata, do pre-install checks.
*/
bundle := loadBundleInfo(path.Join(workDir, "bundle.json"), cleanup)
pathSet := pathSetByApp(bundle.ID)
app := bundle
if s, err := os.Stat(pathSet.metaPath); err != nil {
if !os.IsNotExist(err) {
cleanup()
log.Fatalf("cannot access %q: %v", pathSet.metaPath, err)
}
// did not modify app, clean installation condition met later
} else if s.IsDir() {
cleanup()
log.Fatalf("metadata path %q is not a file", pathSet.metaPath)
} else {
app = loadBundleInfo(pathSet.metaPath, cleanup)
if app.ID != bundle.ID {
cleanup()
log.Fatalf("app %q claims to have identifier %q", bundle.ID, app.ID)
}
// sec: should verify credentials
}
if app != bundle {
// do not try to re-install
if app.NixGL == bundle.NixGL &&
app.CurrentSystem == bundle.CurrentSystem &&
app.Launcher == bundle.Launcher &&
app.ActivationPackage == bundle.ActivationPackage {
cleanup()
log.Printf("package %q is identical to local application %q", pkgPath, app.ID)
internal.Exit(0)
}
// AppID determines uid
if app.AppID != bundle.AppID {
cleanup()
log.Fatalf("package %q app id %d differs from installed %d", pkgPath, bundle.AppID, app.AppID)
}
// sec: should compare version string
fmsg.Verbosef("installing application %q version %q over local %q", bundle.ID, bundle.Version, app.Version)
} else {
fmsg.Verbosef("application %q clean installation", bundle.ID)
// sec: should install credentials
}
/*
Setup steps for files owned by the target user.
*/
withCacheDir("install", []string{
// export inner bundle path in the environment
"export BUNDLE=" + fst.Tmp + "/bundle",
// replace inner /etc
"mkdir -p etc",
"chmod -R +w etc",
"rm -rf etc",
"cp -dRf $BUNDLE/etc etc",
// replace inner /nix
"mkdir -p nix",
"chmod -R +w nix",
"rm -rf nix",
"cp -dRf /nix nix",
// copy from binary cache
"nix copy --offline --no-check-sigs --all --from file://$BUNDLE/res --to $PWD",
// deduplicate nix store
"nix store --offline --store $PWD optimise",
// make cache directory world-readable for autoetc
"chmod 0755 .",
}, workDir, bundle, pathSet, dropShellInstall, cleanup)
if bundle.GPU {
withCacheDir("mesa-wrappers", []string{
// link nixGL mesa wrappers
"mkdir -p nix/.nixGL",
"ln -s " + bundle.Mesa + "/bin/nixGLIntel nix/.nixGL/nixGL",
"ln -s " + bundle.Mesa + "/bin/nixVulkanIntel nix/.nixGL/nixVulkan",
}, workDir, bundle, pathSet, false, cleanup)
}
/*
Activate home-manager generation.
*/
withNixDaemon("activate", []string{
// clean up broken links
"mkdir -p .local/state/{nix,home-manager}",
"chmod -R +w .local/state/{nix,home-manager}",
"rm -rf .local/state/{nix,home-manager}",
// run activation script
bundle.ActivationPackage + "/activate",
}, false, func(config *fst.Config) *fst.Config { return config }, bundle, pathSet, dropShellActivate, cleanup)
/*
Installation complete. Write metadata to block re-installs or downgrades.
*/
// serialise metadata to ensure consistency
if f, err := os.OpenFile(pathSet.metaPath+"~", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644); err != nil {
cleanup()
log.Fatalf("cannot create metadata file: %v", err)
} else if err = json.NewEncoder(f).Encode(bundle); err != nil {
cleanup()
log.Fatalf("cannot write metadata: %v", err)
} else if err = f.Close(); err != nil {
log.Printf("cannot close metadata file: %v", err)
// not fatal
}
if err := os.Rename(pathSet.metaPath+"~", pathSet.metaPath); err != nil {
cleanup()
log.Fatalf("cannot rename metadata file: %v", err)
}
cleanup()
}

View File

@ -1,351 +1,50 @@
package main package main
import ( import (
"context" "flag"
"encoding/json"
"errors"
"log" "log"
"os" "os"
"os/signal"
"path"
"syscall"
"git.gensokyo.uk/security/fortify/command"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal" "git.gensokyo.uk/security/fortify/internal"
"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/sys"
"git.gensokyo.uk/security/fortify/sandbox"
) )
const shellPath = "/run/current-system/sw/bin/bash" const shellPath = "/run/current-system/sw/bin/bash"
var (
errSuccess = errors.New("success")
std sys.State = new(sys.Std)
)
func init() { func init() {
fmsg.Prepare("fpkg")
if err := os.Setenv("SHELL", shellPath); err != nil { if err := os.Setenv("SHELL", shellPath); err != nil {
log.Fatalf("cannot set $SHELL: %v", err) log.Fatalf("cannot set $SHELL: %v", err)
} }
} }
func main() { var (
// early init path, skips root check and duplicate PR_SET_DUMPABLE
sandbox.TryArgv0(fmsg.Output{}, fmsg.Prepare, internal.InstallFmsg)
if err := sandbox.SetDumpable(sandbox.SUID_DUMP_DISABLE); err != nil {
log.Printf("cannot set SUID_DUMP_DISABLE: %s", err)
// not fatal: this program runs as the privileged user
}
if os.Geteuid() == 0 {
log.Fatal("this program must not run as root")
}
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop() // unreachable
var (
flagVerbose bool flagVerbose bool
flagDropShell bool )
)
c := command.New(os.Stderr, log.Printf, "fpkg", func([]string) error {
internal.InstallFmsg(flagVerbose)
return nil
}).
Flag(&flagVerbose, "v", command.BoolFlag(false), "Print debug messages to the console").
Flag(&flagDropShell, "s", command.BoolFlag(false), "Drop to a shell in place of next fortify action")
c.Command("shim", command.UsageInternal, func([]string) error { app.ShimMain(); return errSuccess }) func init() {
flag.BoolVar(&flagVerbose, "v", false, "Verbose output")
{ }
var (
flagDropShellActivate bool func main() {
) fmsg.Prepare("fpkg")
c.NewCommand("install", "Install an application from its package", func(args []string) error {
if len(args) != 1 { flag.Parse()
log.Println("invalid argument") fmsg.Store(flagVerbose)
return syscall.EINVAL
} args := flag.Args()
pkgPath := args[0] if len(args) < 1 {
if !path.IsAbs(pkgPath) { log.Fatal("invalid argument")
if dir, err := os.Getwd(); err != nil { }
log.Printf("cannot get current directory: %v", err)
return err switch args[0] {
} else { case "install":
pkgPath = path.Join(dir, pkgPath) actionInstall(args[1:])
} case "start":
} actionStart(args[1:])
/* default:
Look up paths to programs started by fpkg. log.Fatal("invalid argument")
This is done here to ease error handling as cleanup is not yet required. }
*/
internal.Exit(0)
var (
_ = lookPath("zstd")
tar = lookPath("tar")
chmod = lookPath("chmod")
rm = lookPath("rm")
)
/*
Extract package and set up for cleanup.
*/
var workDir string
if p, err := os.MkdirTemp("", "fpkg.*"); err != nil {
log.Printf("cannot create temporary directory: %v", err)
return err
} else {
workDir = p
}
cleanup := func() {
// should be faster than a native implementation
mustRun(chmod, "-R", "+w", workDir)
mustRun(rm, "-rf", workDir)
}
beforeRunFail.Store(&cleanup)
mustRun(tar, "-C", workDir, "-xf", pkgPath)
/*
Parse bundle and app metadata, do pre-install checks.
*/
bundle := loadAppInfo(path.Join(workDir, "bundle.json"), cleanup)
pathSet := pathSetByApp(bundle.ID)
a := bundle
if s, err := os.Stat(pathSet.metaPath); err != nil {
if !os.IsNotExist(err) {
cleanup()
log.Printf("cannot access %q: %v", pathSet.metaPath, err)
return err
}
// did not modify app, clean installation condition met later
} else if s.IsDir() {
cleanup()
log.Printf("metadata path %q is not a file", pathSet.metaPath)
return syscall.EBADMSG
} else {
a = loadAppInfo(pathSet.metaPath, cleanup)
if a.ID != bundle.ID {
cleanup()
log.Printf("app %q claims to have identifier %q",
bundle.ID, a.ID)
return syscall.EBADE
}
// sec: should verify credentials
}
if a != bundle {
// do not try to re-install
if a.NixGL == bundle.NixGL &&
a.CurrentSystem == bundle.CurrentSystem &&
a.Launcher == bundle.Launcher &&
a.ActivationPackage == bundle.ActivationPackage {
cleanup()
log.Printf("package %q is identical to local application %q",
pkgPath, a.ID)
return errSuccess
}
// AppID determines uid
if a.AppID != bundle.AppID {
cleanup()
log.Printf("package %q app id %d differs from installed %d",
pkgPath, bundle.AppID, a.AppID)
return syscall.EBADE
}
// sec: should compare version string
fmsg.Verbosef("installing application %q version %q over local %q",
bundle.ID, bundle.Version, a.Version)
} else {
fmsg.Verbosef("application %q clean installation", bundle.ID)
// sec: should install credentials
}
/*
Setup steps for files owned by the target user.
*/
withCacheDir(ctx, "install", []string{
// export inner bundle path in the environment
"export BUNDLE=" + fst.Tmp + "/bundle",
// replace inner /etc
"mkdir -p etc",
"chmod -R +w etc",
"rm -rf etc",
"cp -dRf $BUNDLE/etc etc",
// replace inner /nix
"mkdir -p nix",
"chmod -R +w nix",
"rm -rf nix",
"cp -dRf /nix nix",
// copy from binary cache
"nix copy --offline --no-check-sigs --all --from file://$BUNDLE/res --to $PWD",
// deduplicate nix store
"nix store --offline --store $PWD optimise",
// make cache directory world-readable for autoetc
"chmod 0755 .",
}, workDir, bundle, pathSet, flagDropShell, cleanup)
if bundle.GPU {
withCacheDir(ctx, "mesa-wrappers", []string{
// link nixGL mesa wrappers
"mkdir -p nix/.nixGL",
"ln -s " + bundle.Mesa + "/bin/nixGLIntel nix/.nixGL/nixGL",
"ln -s " + bundle.Mesa + "/bin/nixVulkanIntel nix/.nixGL/nixVulkan",
}, workDir, bundle, pathSet, false, cleanup)
}
/*
Activate home-manager generation.
*/
withNixDaemon(ctx, "activate", []string{
// clean up broken links
"mkdir -p .local/state/{nix,home-manager}",
"chmod -R +w .local/state/{nix,home-manager}",
"rm -rf .local/state/{nix,home-manager}",
// run activation script
bundle.ActivationPackage + "/activate",
}, false, func(config *fst.Config) *fst.Config { return config },
bundle, pathSet, flagDropShellActivate, cleanup)
/*
Installation complete. Write metadata to block re-installs or downgrades.
*/
// serialise metadata to ensure consistency
if f, err := os.OpenFile(pathSet.metaPath+"~", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644); err != nil {
cleanup()
log.Printf("cannot create metadata file: %v", err)
return err
} else if err = json.NewEncoder(f).Encode(bundle); err != nil {
cleanup()
log.Printf("cannot write metadata: %v", err)
return err
} else if err = f.Close(); err != nil {
log.Printf("cannot close metadata file: %v", err)
// not fatal
}
if err := os.Rename(pathSet.metaPath+"~", pathSet.metaPath); err != nil {
cleanup()
log.Printf("cannot rename metadata file: %v", err)
return err
}
cleanup()
return errSuccess
}).
Flag(&flagDropShellActivate, "s", command.BoolFlag(false), "Drop to a shell on activation")
}
{
var (
flagDropShellNixGL bool
flagAutoDrivers bool
)
c.NewCommand("start", "Start an application", func(args []string) error {
if len(args) < 1 {
log.Println("invalid argument")
return syscall.EINVAL
}
/*
Parse app metadata.
*/
id := args[0]
pathSet := pathSetByApp(id)
a := loadAppInfo(pathSet.metaPath, func() {})
if a.ID != id {
log.Printf("app %q claims to have identifier %q", id, a.ID)
return syscall.EBADE
}
/*
Prepare nixGL.
*/
if a.GPU && flagAutoDrivers {
withNixDaemon(ctx, "nix-gl", []string{
"mkdir -p /nix/.nixGL/auto",
"rm -rf /nix/.nixGL/auto",
"export NIXPKGS_ALLOW_UNFREE=1",
"nix build --impure " +
"--out-link /nix/.nixGL/auto/opengl " +
"--override-input nixpkgs path:/etc/nixpkgs " +
"path:" + a.NixGL,
"nix build --impure " +
"--out-link /nix/.nixGL/auto/vulkan " +
"--override-input nixpkgs path:/etc/nixpkgs " +
"path:" + a.NixGL + "#nixVulkanNvidia",
}, true, func(config *fst.Config) *fst.Config {
config.Confinement.Sandbox.Filesystem = append(config.Confinement.Sandbox.Filesystem, []*fst.FilesystemConfig{
{Src: "/etc/resolv.conf"},
{Src: "/sys/block"},
{Src: "/sys/bus"},
{Src: "/sys/class"},
{Src: "/sys/dev"},
{Src: "/sys/devices"},
}...)
appendGPUFilesystem(config)
return config
}, a, pathSet, flagDropShellNixGL, func() {})
}
/*
Create app configuration.
*/
argv := make([]string, 1, len(args))
if !flagDropShell {
argv[0] = a.Launcher
} else {
argv[0] = shellPath
}
argv = append(argv, args[1:]...)
config := a.toFst(pathSet, argv, flagDropShell)
/*
Expose GPU devices.
*/
if a.GPU {
config.Confinement.Sandbox.Filesystem = append(config.Confinement.Sandbox.Filesystem,
&fst.FilesystemConfig{Src: path.Join(pathSet.nixPath, ".nixGL"), Dst: path.Join(fst.Tmp, "nixGL")})
appendGPUFilesystem(config)
}
/*
Spawn app.
*/
mustRunApp(ctx, config, func() {})
return errSuccess
}).
Flag(&flagDropShellNixGL, "s", command.BoolFlag(false), "Drop to a shell on nixGL build").
Flag(&flagAutoDrivers, "auto-drivers", command.BoolFlag(false), "Attempt automatic opengl driver detection")
}
c.MustParse(os.Args[1:], func(err error) {
fmsg.Verbosef("command returned %v", err)
if errors.Is(err, errSuccess) {
fmsg.BeforeExit()
os.Exit(0)
}
})
log.Fatal("unreachable")
} }

View File

@ -8,7 +8,6 @@ import (
"strconv" "strconv"
"sync/atomic" "sync/atomic"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
@ -70,32 +69,3 @@ func pathSetByApp(id string) *appPathSet {
pathSet.nixPath = path.Join(pathSet.cacheDir, "nix") pathSet.nixPath = path.Join(pathSet.cacheDir, "nix")
return pathSet return pathSet
} }
func appendGPUFilesystem(config *fst.Config) {
config.Confinement.Sandbox.Filesystem = append(config.Confinement.Sandbox.Filesystem, []*fst.FilesystemConfig{
// flatpak commit 763a686d874dd668f0236f911de00b80766ffe79
{Src: "/dev/dri", Device: true},
// mali
{Src: "/dev/mali", Device: true},
{Src: "/dev/mali0", Device: true},
{Src: "/dev/umplock", Device: true},
// nvidia
{Src: "/dev/nvidiactl", Device: true},
{Src: "/dev/nvidia-modeset", Device: true},
// nvidia OpenCL/CUDA
{Src: "/dev/nvidia-uvm", Device: true},
{Src: "/dev/nvidia-uvm-tools", Device: true},
// flatpak commit d2dff2875bb3b7e2cd92d8204088d743fd07f3ff
{Src: "/dev/nvidia0", Device: true}, {Src: "/dev/nvidia1", Device: true},
{Src: "/dev/nvidia2", Device: true}, {Src: "/dev/nvidia3", Device: true},
{Src: "/dev/nvidia4", Device: true}, {Src: "/dev/nvidia5", Device: true},
{Src: "/dev/nvidia6", Device: true}, {Src: "/dev/nvidia7", Device: true},
{Src: "/dev/nvidia8", Device: true}, {Src: "/dev/nvidia9", Device: true},
{Src: "/dev/nvidia10", Device: true}, {Src: "/dev/nvidia11", Device: true},
{Src: "/dev/nvidia12", Device: true}, {Src: "/dev/nvidia13", Device: true},
{Src: "/dev/nvidia14", Device: true}, {Src: "/dev/nvidia15", Device: true},
{Src: "/dev/nvidia16", Device: true}, {Src: "/dev/nvidia17", Device: true},
{Src: "/dev/nvidia18", Device: true}, {Src: "/dev/nvidia19", Device: true},
}...)
}

View File

@ -1,28 +1,65 @@
package main package main
import ( import (
"context" "encoding/json"
"errors"
"io"
"log"
"os" "os"
"os/exec"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/app" "git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
func mustRunApp(ctx context.Context, config *fst.Config, beforeFail func()) { const compPoison = "INVALIDINVALIDINVALIDINVALIDINVALID"
rs := new(fst.RunState)
a := app.MustNew(ctx, std)
if sa, err := a.Seal(config); err != nil { var (
fmsg.PrintBaseError(err, "cannot seal app:") Fmain = compPoison
rs.ExitCode = 1 )
func fortifyApp(config *fst.Config, beforeFail func()) {
var (
cmd *exec.Cmd
st io.WriteCloser
)
if p, ok := internal.Path(Fmain); !ok {
beforeFail()
log.Fatal("invalid fortify path, this copy of fpkg is not compiled correctly")
} else if r, w, err := os.Pipe(); err != nil {
beforeFail()
log.Fatalf("cannot pipe: %v", err)
} else { } else {
// this updates ExitCode if fmsg.Load() {
app.PrintRunStateErr(rs, sa.Run(rs)) cmd = exec.Command(p, "-v", "app", "3")
} else {
cmd = exec.Command(p, "app", "3")
}
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.ExtraFiles = []*os.File{r}
st = w
} }
if rs.ExitCode != 0 { go func() {
if err := json.NewEncoder(st).Encode(config); err != nil {
beforeFail() beforeFail()
os.Exit(rs.ExitCode) log.Fatalf("cannot send configuration: %v", err)
}
}()
if err := cmd.Start(); err != nil {
beforeFail()
log.Fatalf("cannot start fortify: %v", err)
}
if err := cmd.Wait(); err != nil {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
beforeFail()
internal.Exit(exitError.ExitCode())
} else {
beforeFail()
log.Fatalf("cannot wait: %v", err)
}
} }
} }

178
cmd/fpkg/start.go Normal file
View File

@ -0,0 +1,178 @@
package main
import (
"flag"
"log"
"path"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal"
)
func actionStart(args []string) {
set := flag.NewFlagSet("start", flag.ExitOnError)
var (
dropShell bool
dropShellNixGL bool
autoDrivers bool
)
set.BoolVar(&dropShell, "s", false, "Drop to a shell")
set.BoolVar(&dropShellNixGL, "sg", false, "Drop to a shell on nixGL build")
set.BoolVar(&autoDrivers, "autodrivers", false, "Attempt automatic opengl driver detection")
// Ignore errors; set is set for ExitOnError.
_ = set.Parse(args)
args = set.Args()
if len(args) < 1 {
log.Fatal("invalid argument")
}
/*
Parse app metadata.
*/
id := args[0]
pathSet := pathSetByApp(id)
app := loadBundleInfo(pathSet.metaPath, func() {})
if app.ID != id {
log.Fatalf("app %q claims to have identifier %q", id, app.ID)
}
/*
Prepare nixGL.
*/
if app.GPU && autoDrivers {
withNixDaemon("nix-gl", []string{
"mkdir -p /nix/.nixGL/auto",
"rm -rf /nix/.nixGL/auto",
"export NIXPKGS_ALLOW_UNFREE=1",
"nix build --impure " +
"--out-link /nix/.nixGL/auto/opengl " +
"--override-input nixpkgs path:/etc/nixpkgs " +
"path:" + app.NixGL,
"nix build --impure " +
"--out-link /nix/.nixGL/auto/vulkan " +
"--override-input nixpkgs path:/etc/nixpkgs " +
"path:" + app.NixGL + "#nixVulkanNvidia",
}, true, func(config *fst.Config) *fst.Config {
config.Confinement.Sandbox.Filesystem = append(config.Confinement.Sandbox.Filesystem, []*fst.FilesystemConfig{
{Src: "/etc/resolv.conf"},
{Src: "/sys/block"},
{Src: "/sys/bus"},
{Src: "/sys/class"},
{Src: "/sys/dev"},
{Src: "/sys/devices"},
}...)
appendGPUFilesystem(config)
return config
}, app, pathSet, dropShellNixGL, func() {})
}
/*
Create app configuration.
*/
command := make([]string, 1, len(args))
if !dropShell {
command[0] = app.Launcher
} else {
command[0] = shellPath
}
command = append(command, args[1:]...)
config := &fst.Config{
ID: app.ID,
Command: command,
Confinement: fst.ConfinementConfig{
AppID: app.AppID,
Groups: app.Groups,
Username: "fortify",
Inner: path.Join("/data/data", app.ID),
Outer: pathSet.homeDir,
Sandbox: &fst.SandboxConfig{
Hostname: formatHostname(app.Name),
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,
Filesystem: []*fst.FilesystemConfig{
{Src: path.Join(pathSet.nixPath, "store"), Dst: "/nix/store", Must: true},
{Src: pathSet.metaPath, Dst: path.Join(fst.Tmp, "app"), Must: true},
{Src: "/etc/resolv.conf"},
{Src: "/sys/block"},
{Src: "/sys/bus"},
{Src: "/sys/class"},
{Src: "/sys/dev"},
{Src: "/sys/devices"},
},
Link: [][2]string{
{app.CurrentSystem, "/run/current-system"},
{"/run/current-system/sw/bin", "/bin"},
{"/run/current-system/sw/bin", "/usr/bin"},
},
Etc: path.Join(pathSet.cacheDir, "etc"),
AutoEtc: true,
},
ExtraPerms: []*fst.ExtraPermConfig{
{Path: dataHome, Execute: true},
{Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true},
},
SystemBus: app.SystemBus,
SessionBus: app.SessionBus,
Enablements: app.Enablements,
},
}
/*
Expose GPU devices.
*/
if app.GPU {
config.Confinement.Sandbox.Filesystem = append(config.Confinement.Sandbox.Filesystem,
&fst.FilesystemConfig{Src: path.Join(pathSet.nixPath, ".nixGL"), Dst: path.Join(fst.Tmp, "nixGL")})
appendGPUFilesystem(config)
}
/*
Spawn app.
*/
fortifyApp(config, func() {})
internal.Exit(0)
}
func appendGPUFilesystem(config *fst.Config) {
config.Confinement.Sandbox.Filesystem = append(config.Confinement.Sandbox.Filesystem, []*fst.FilesystemConfig{
// flatpak commit 763a686d874dd668f0236f911de00b80766ffe79
{Src: "/dev/dri", Device: true},
// mali
{Src: "/dev/mali", Device: true},
{Src: "/dev/mali0", Device: true},
{Src: "/dev/umplock", Device: true},
// nvidia
{Src: "/dev/nvidiactl", Device: true},
{Src: "/dev/nvidia-modeset", Device: true},
// nvidia OpenCL/CUDA
{Src: "/dev/nvidia-uvm", Device: true},
{Src: "/dev/nvidia-uvm-tools", Device: true},
// flatpak commit d2dff2875bb3b7e2cd92d8204088d743fd07f3ff
{Src: "/dev/nvidia0", Device: true}, {Src: "/dev/nvidia1", Device: true},
{Src: "/dev/nvidia2", Device: true}, {Src: "/dev/nvidia3", Device: true},
{Src: "/dev/nvidia4", Device: true}, {Src: "/dev/nvidia5", Device: true},
{Src: "/dev/nvidia6", Device: true}, {Src: "/dev/nvidia7", Device: true},
{Src: "/dev/nvidia8", Device: true}, {Src: "/dev/nvidia9", Device: true},
{Src: "/dev/nvidia10", Device: true}, {Src: "/dev/nvidia11", Device: true},
{Src: "/dev/nvidia12", Device: true}, {Src: "/dev/nvidia13", Device: true},
{Src: "/dev/nvidia14", Device: true}, {Src: "/dev/nvidia15", Device: true},
{Src: "/dev/nvidia16", Device: true}, {Src: "/dev/nvidia17", Device: true},
{Src: "/dev/nvidia18", Device: true}, {Src: "/dev/nvidia19", Device: true},
}...)
}

View File

@ -1,60 +0,0 @@
{ pkgs, ... }:
{
users.users = {
alice = {
isNormalUser = true;
description = "Alice Foobar";
password = "foobar";
uid = 1000;
};
};
home-manager.users.alice.home.stateVersion = "24.11";
# Automatically login on tty1 as a normal user:
services.getty.autologinUser = "alice";
environment = {
variables = {
SWAYSOCK = "/tmp/sway-ipc.sock";
WLR_RENDERER = "pixman";
};
};
# Automatically configure and start Sway when logging in on tty1:
programs.bash.loginShellInit = ''
if [ "$(tty)" = "/dev/tty1" ]; then
set -e
mkdir -p ~/.config/sway
(sed s/Mod4/Mod1/ /etc/sway/config &&
echo 'output * bg ${pkgs.nixos-artwork.wallpapers.simple-light-gray.gnomeFilePath} fill' &&
echo 'output Virtual-1 res 1680x1050') > ~/.config/sway/config
sway --validate
systemd-cat --identifier=session sway && touch /tmp/sway-exit-ok
fi
'';
programs.sway.enable = true;
virtualisation = {
diskSize = 6 * 1024;
qemu.options = [
# Need to switch to a different GPU driver than the default one (-vga std) so that Sway can launch:
"-vga none -device virtio-gpu-pci"
# Increase zstd performance:
"-smp 8"
];
};
environment.fortify = {
enable = true;
stateDir = "/var/lib/fortify";
users.alice = 0;
home-manager = _: _: { home.stateVersion = "23.05"; };
};
}

View File

@ -1,34 +0,0 @@
{
nixosTest,
callPackage,
system,
self,
}:
let
buildPackage = self.buildPackage.${system};
in
nixosTest {
name = "fpkg";
nodes.machine = {
environment.etc = {
"foot.pkg".source = callPackage ./foot.nix { inherit buildPackage; };
};
imports = [
./configuration.nix
self.nixosModules.fortify
self.inputs.home-manager.nixosModules.home-manager
];
};
# adapted from nixos sway integration tests
# testScriptWithTypes:49: error: Cannot call function of unknown type
# (machine.succeed if succeed else machine.execute)(
# ^
# Found 1 error in 1 file (checked 1 source file)
skipTypeCheck = true;
testScript = builtins.readFile ./test.py;
}

View File

@ -1,48 +0,0 @@
{
lib,
buildPackage,
foot,
wayland-utils,
inconsolata,
}:
buildPackage {
name = "foot";
inherit (foot) version;
app_id = 2;
id = "org.codeberg.dnkl.foot";
modules = [
{
home.packages = [
foot
# For wayland-info:
wayland-utils
];
}
];
nixosModules = [
{
# To help with OCR:
environment.etc."xdg/foot/foot.ini".text = lib.generators.toINI { } {
main = {
font = "inconsolata:size=14";
};
colors = rec {
foreground = "000000";
background = "ffffff";
regular2 = foreground;
};
};
fonts.packages = [ inconsolata ];
}
];
script = ''
exec foot "$@"
'';
}

View File

@ -1,108 +0,0 @@
import json
import shlex
q = shlex.quote
NODE_GROUPS = ["nodes", "floating_nodes"]
def swaymsg(command: str = "", succeed=True, type="command"):
assert command != "" or type != "command", "Must specify command or type"
shell = q(f"swaymsg -t {q(type)} -- {q(command)}")
with machine.nested(
f"sending swaymsg {shell!r}" + " (allowed to fail)" * (not succeed)
):
ret = (machine.succeed if succeed else machine.execute)(
f"su - alice -c {shell}"
)
# execute also returns a status code, but disregard.
if not succeed:
_, ret = ret
if not succeed and not ret:
return None
parsed = json.loads(ret)
return parsed
def walk(tree):
yield tree
for group in NODE_GROUPS:
for node in tree.get(group, []):
yield from walk(node)
def wait_for_window(pattern):
def func(last_chance):
nodes = (node["name"] for node in walk(swaymsg(type="get_tree")))
if last_chance:
nodes = list(nodes)
machine.log(f"Last call! Current list of windows: {nodes}")
return any(pattern in name for name in nodes)
retry(func)
def collect_state_ui(name):
swaymsg(f"exec fortify ps > '/tmp/{name}.ps'")
machine.copy_from_vm(f"/tmp/{name}.ps", "")
swaymsg(f"exec fortify --json ps > '/tmp/{name}.json'")
machine.copy_from_vm(f"/tmp/{name}.json", "")
machine.screenshot(name)
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()))
config = instance['config']
if len(config['args']) != 1 or not (config['args'][0].startswith("/nix/store/")) or f"fortify-{name}-" not in (config['args'][0]):
raise Exception(f"unexpected args {instance['config']['args']}")
if config['confinement']['enablements'] != enablements:
raise Exception(f"unexpected enablements {instance['config']['confinement']['enablements']}")
start_all()
machine.wait_for_unit("multi-user.target")
# To check fortify's version:
print(machine.succeed("sudo -u alice -i fortify version"))
# Wait for Sway to complete startup:
machine.wait_for_file("/run/user/1000/wayland-1")
machine.wait_for_file("/tmp/sway-ipc.sock")
# Prepare fpkg directory:
machine.succeed("install -dm 0700 -o alice -g users /var/lib/fortify/1000")
# Install fpkg app:
swaymsg("exec fpkg -v install /etc/foot.pkg && touch /tmp/fpkg-install-done")
machine.wait_for_file("/tmp/fpkg-install-done")
# Start app (foot) with Wayland enablement:
swaymsg("exec fpkg -v start org.codeberg.dnkl.foot")
wait_for_window("fortify@machine-foot")
machine.send_chars("clear; wayland-info && touch /tmp/success-client\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/2/success-client")
collect_state_ui("app_wayland")
check_state("foot", 13)
# Verify acl on XDG_RUNTIME_DIR:
print(machine.succeed("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000002"))
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 1000002")
# Exit Sway and verify process exit status 0:
swaymsg("exit", succeed=False)
machine.wait_for_file("/tmp/sway-exit-ok")
# Print fortify runDir contents:
print(machine.succeed("find /run/user/1000/fortify"))

View File

@ -1,24 +1,21 @@
package main package main
import ( import (
"context"
"path" "path"
"strings" "strings"
"git.gensokyo.uk/security/fortify/fst" "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"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
) )
func withNixDaemon( func withNixDaemon(
ctx context.Context,
action string, command []string, net bool, updateConfig func(config *fst.Config) *fst.Config, action string, command []string, net bool, updateConfig func(config *fst.Config) *fst.Config,
app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func(), app *bundleInfo, pathSet *appPathSet, dropShell bool, beforeFail func(),
) { ) {
mustRunAppDropShell(ctx, updateConfig(&fst.Config{ fortifyAppDropShell(updateConfig(&fst.Config{
ID: app.ID, ID: app.ID,
Path: shellPath, Command: []string{shellPath, "-lc", "rm -f /nix/var/nix/daemon-socket/socket && " +
Args: []string{shellPath, "-lc", "rm -f /nix/var/nix/daemon-socket/socket && " +
// start nix-daemon // start nix-daemon
"nix-daemon --store / & " + "nix-daemon --store / & " +
// wait for socket to appear // wait for socket to appear
@ -36,10 +33,10 @@ func withNixDaemon(
Outer: pathSet.homeDir, Outer: pathSet.homeDir,
Sandbox: &fst.SandboxConfig{ Sandbox: &fst.SandboxConfig{
Hostname: formatHostname(app.Name) + "-" + action, Hostname: formatHostname(app.Name) + "-" + action,
Userns: true, // nix sandbox requires userns UserNS: true, // nix sandbox requires userns
Net: net, Net: net,
Seccomp: seccomp.FlagMultiarch, Syscall: &bwrap.SyscallPolicy{Multiarch: true},
Tty: dropShell, NoNewSession: dropShell,
Filesystem: []*fst.FilesystemConfig{ Filesystem: []*fst.FilesystemConfig{
{Src: pathSet.nixPath, Dst: "/nix", Write: true, Must: true}, {Src: pathSet.nixPath, Dst: "/nix", Write: true, Must: true},
}, },
@ -59,14 +56,10 @@ func withNixDaemon(
}), dropShell, beforeFail) }), dropShell, beforeFail)
} }
func withCacheDir( func withCacheDir(action string, command []string, workDir string, app *bundleInfo, pathSet *appPathSet, dropShell bool, beforeFail func()) {
ctx context.Context, fortifyAppDropShell(&fst.Config{
action string, command []string, workDir string,
app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func()) {
mustRunAppDropShell(ctx, &fst.Config{
ID: app.ID, ID: app.ID,
Path: shellPath, Command: []string{shellPath, "-lc", strings.Join(command, " && ")},
Args: []string{shellPath, "-lc", strings.Join(command, " && ")},
Confinement: fst.ConfinementConfig{ Confinement: fst.ConfinementConfig{
AppID: app.AppID, AppID: app.AppID,
Username: "nixos", Username: "nixos",
@ -74,8 +67,8 @@ func withCacheDir(
Outer: pathSet.cacheDir, // this also ensures cacheDir via shim Outer: pathSet.cacheDir, // this also ensures cacheDir via shim
Sandbox: &fst.SandboxConfig{ Sandbox: &fst.SandboxConfig{
Hostname: formatHostname(app.Name) + "-" + action, Hostname: formatHostname(app.Name) + "-" + action,
Seccomp: seccomp.FlagMultiarch, Syscall: &bwrap.SyscallPolicy{Multiarch: true},
Tty: dropShell, NoNewSession: dropShell,
Filesystem: []*fst.FilesystemConfig{ Filesystem: []*fst.FilesystemConfig{
{Src: path.Join(workDir, "nix"), Dst: "/nix", Must: true}, {Src: path.Join(workDir, "nix"), Dst: "/nix", Must: true},
{Src: workDir, Dst: path.Join(fst.Tmp, "bundle"), Must: true}, {Src: workDir, Dst: path.Join(fst.Tmp, "bundle"), Must: true},
@ -97,12 +90,12 @@ func withCacheDir(
}, dropShell, beforeFail) }, dropShell, beforeFail)
} }
func mustRunAppDropShell(ctx context.Context, config *fst.Config, dropShell bool, beforeFail func()) { func fortifyAppDropShell(config *fst.Config, dropShell bool, beforeFail func()) {
if dropShell { if dropShell {
config.Args = []string{shellPath, "-l"} config.Command = []string{shellPath, "-l"}
mustRunApp(ctx, config, beforeFail) fortifyApp(config, beforeFail)
beforeFail() beforeFail()
internal.Exit(0) internal.Exit(0)
} }
mustRunApp(ctx, config, beforeFail) fortifyApp(config, beforeFail)
} }

View File

@ -13,6 +13,7 @@ import (
) )
const ( const (
compPoison = "INVALIDINVALIDINVALIDINVALIDINVALID"
fsuConfFile = "/etc/fsurc" fsuConfFile = "/etc/fsurc"
envShim = "FORTIFY_SHIM" envShim = "FORTIFY_SHIM"
envAID = "FORTIFY_APP_ID" envAID = "FORTIFY_APP_ID"
@ -21,6 +22,10 @@ const (
PR_SET_NO_NEW_PRIVS = 0x26 PR_SET_NO_NEW_PRIVS = 0x26
) )
var (
Fmain = compPoison
)
func main() { func main() {
log.SetFlags(0) log.SetFlags(0)
log.SetPrefix("fsu: ") log.SetPrefix("fsu: ")
@ -35,16 +40,20 @@ func main() {
log.Fatal("this program must not be started by root") log.Fatal("this program must not be started by root")
} }
var toolPath string var fmain string
if p, ok := checkPath(Fmain); !ok {
log.Fatal("invalid fortify path, this copy of fsu is not compiled correctly")
} else {
fmain = p
}
pexe := path.Join("/proc", strconv.Itoa(os.Getppid()), "exe") pexe := path.Join("/proc", strconv.Itoa(os.Getppid()), "exe")
if p, err := os.Readlink(pexe); err != nil { if p, err := os.Readlink(pexe); err != nil {
log.Fatalf("cannot read parent executable path: %v", err) log.Fatalf("cannot read parent executable path: %v", err)
} else if strings.HasSuffix(p, " (deleted)") { } else if strings.HasSuffix(p, " (deleted)") {
log.Fatal("fortify executable has been deleted") log.Fatal("fortify executable has been deleted")
} else if p != mustCheckPath(fmain) && p != mustCheckPath(fpkg) { } else if p != fmain {
log.Fatal("this program must be started by fortify") log.Fatal("this program must be started by fortify")
} else {
toolPath = p
} }
// uid = 1000000 + // uid = 1000000 +
@ -138,9 +147,13 @@ func main() {
if _, _, errno := syscall.AllThreadsSyscall(syscall.SYS_PRCTL, PR_SET_NO_NEW_PRIVS, 1, 0); errno != 0 { if _, _, errno := syscall.AllThreadsSyscall(syscall.SYS_PRCTL, PR_SET_NO_NEW_PRIVS, 1, 0); errno != 0 {
log.Fatalf("cannot set no_new_privs flag: %s", errno.Error()) log.Fatalf("cannot set no_new_privs flag: %s", errno.Error())
} }
if err := syscall.Exec(toolPath, []string{"fortify", "shim"}, []string{envShim + "=" + shimSetupFd}); err != nil { if err := syscall.Exec(fmain, []string{"fortify", "shim"}, []string{envShim + "=" + shimSetupFd}); err != nil {
log.Fatalf("cannot start shim: %v", err) log.Fatalf("cannot start shim: %v", err)
} }
panic("unreachable") panic("unreachable")
} }
func checkPath(p string) (string, bool) {
return p, p != compPoison && p != "" && path.IsAbs(p)
}

View File

@ -1,5 +1,4 @@
{ {
lib,
buildGoModule, buildGoModule,
fortify ? abort "fortify package required", fortify ? abort "fortify package required",
}: }:
@ -16,15 +15,5 @@ buildGoModule {
go mod init fsu >& /dev/null go mod init fsu >& /dev/null
''; '';
ldflags = ldflags = [ "-X main.Fmain=${fortify}/libexec/fortify" ];
lib.attrsets.foldlAttrs
(
ldflags: name: value:
ldflags ++ [ "-X main.${name}=${value}" ]
)
[ "-s -w" ]
{
fmain = "${fortify}/libexec/fortify";
fpkg = "${fortify}/libexec/fpkg";
};
} }

View File

@ -1,21 +0,0 @@
package main
import (
"log"
"path"
)
const compPoison = "INVALIDINVALIDINVALIDINVALIDINVALID"
var (
fmain = compPoison
fpkg = compPoison
)
func mustCheckPath(p string) string {
if p != compPoison && p != "" && path.IsAbs(p) {
return p
}
log.Fatal("this program is compiled incorrectly")
return compPoison
}

View File

@ -1,21 +1,14 @@
package dbus_test package dbus_test
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt"
"os"
"os/exec"
"strings" "strings"
"testing" "testing"
"time" "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"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/sandbox"
) )
func TestNew(t *testing.T) { func TestNew(t *testing.T) {
@ -107,20 +100,15 @@ func TestProxy_Seal(t *testing.T) {
} }
func TestProxy_Start_Wait_Close_String(t *testing.T) { func TestProxy_Start_Wait_Close_String(t *testing.T) {
oldWaitDelay := helper.WaitDelay t.Run("sandboxed", func(t *testing.T) {
helper.WaitDelay = 16 * time.Second
t.Cleanup(func() { helper.WaitDelay = oldWaitDelay })
t.Run("sandbox", func(t *testing.T) {
proxyName := dbus.ProxyName
dbus.ProxyName = os.Args[0]
t.Cleanup(func() { dbus.ProxyName = proxyName })
testProxyStartWaitCloseString(t, true) testProxyStartWaitCloseString(t, true)
}) })
t.Run("direct", func(t *testing.T) { testProxyStartWaitCloseString(t, false) }) t.Run("direct", func(t *testing.T) {
testProxyStartWaitCloseString(t, false)
})
} }
func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) { func testProxyStartWaitCloseString(t *testing.T, sandbox bool) {
for id, tc := range testCasePairs() { for id, tc := range testCasePairs() {
// this test does not test errors // this test does not test errors
if tc[0].wantErr { if tc[0].wantErr {
@ -137,33 +125,14 @@ func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) {
}) })
t.Run("proxy for "+id, func(t *testing.T) { t.Run("proxy for "+id, func(t *testing.T) {
helper.InternalReplaceExecCommand(t)
overridePath(t)
p := dbus.New(tc[0].bus, tc[1].bus) p := dbus.New(tc[0].bus, tc[1].bus)
p.CommandContext = func(ctx context.Context) (cmd *exec.Cmd) {
return exec.CommandContext(ctx, os.Args[0], "-test.v",
"-test.run=TestHelperInit", "--", "init")
}
p.CmdF = func(v any) {
if useSandbox {
container := v.(*sandbox.Container)
if container.Args[0] != dbus.ProxyName {
panic(fmt.Sprintf("unexpected argv0 %q", os.Args[0]))
}
container.Args = append([]string{os.Args[0], "-test.run=TestHelperStub", "--"}, container.Args[1:]...)
} else {
cmd := v.(*exec.Cmd)
if cmd.Args[0] != dbus.ProxyName {
panic(fmt.Sprintf("unexpected argv0 %q", os.Args[0]))
}
cmd.Err = nil
cmd.Path = os.Args[0]
cmd.Args = append([]string{os.Args[0], "-test.run=TestHelperStub", "--"}, cmd.Args[1:]...)
}
}
p.FilterF = func(v []byte) []byte { return bytes.SplitN(v, []byte("TestHelperInit\n"), 2)[1] }
output := new(strings.Builder) output := new(strings.Builder)
t.Run("unsealed", func(t *testing.T) { t.Run("unsealed behaviour of "+id, func(t *testing.T) {
t.Run("string", func(t *testing.T) { t.Run("unsealed string of "+id, func(t *testing.T) {
want := "(unsealed dbus proxy)" want := "(unsealed dbus proxy)"
if got := p.String(); got != want { if got := p.String(); got != want {
t.Errorf("String() = %v, want %v", t.Errorf("String() = %v, want %v",
@ -172,16 +141,16 @@ func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) {
} }
}) })
t.Run("start", 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, useSandbox); err == nil || err.Error() != want { if err := p.Start(context.Background(), nil, sandbox); 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
} }
}) })
t.Run("wait", func(t *testing.T) { t.Run("unsealed wait of "+id, func(t *testing.T) {
wantErr := "dbus: not started" wantErr := "dbus: 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",
@ -199,7 +168,7 @@ func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) {
} }
}) })
t.Run("sealed", func(t *testing.T) { t.Run("sealed behaviour of "+id, func(t *testing.T) {
want := strings.Join(append(tc[0].want, tc[1].want...), " ") want := strings.Join(append(tc[0].want, tc[1].want...), " ")
if got := p.String(); got != want { if got := p.String(); got != want {
t.Errorf("String() = %v, want %v", t.Errorf("String() = %v, want %v",
@ -207,20 +176,17 @@ func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) {
return return
} }
t.Run("start", func(t *testing.T) { t.Run("sealed start of "+id, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if err := p.Start(ctx, output, useSandbox); err != nil { 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)
} }
t.Run("string", func(t *testing.T) { t.Run("started string of "+id, func(t *testing.T) {
wantSubstr := fmt.Sprintf("%s -test.run=TestHelperStub -- --args=3 --fd=4", os.Args[0]) wantSubstr := dbus.ProxyName + " --args="
if useSandbox {
wantSubstr = fmt.Sprintf(`argv: ["%s" "-test.run=TestHelperStub" "--" "--args=3" "--fd=4"], flags: 0x0, seccomp: 0x3e`, os.Args[0])
}
if got := p.String(); !strings.Contains(got, wantSubstr) { if got := p.String(); !strings.Contains(got, wantSubstr) {
t.Errorf("String() = %v, want %v", t.Errorf("String() = %v, want %v",
p.String(), wantSubstr) p.String(), wantSubstr)
@ -228,7 +194,7 @@ func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) {
} }
}) })
t.Run("wait", func(t *testing.T) { t.Run("started wait of "+id, func(t *testing.T) {
p.Close() 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",
@ -241,10 +207,10 @@ func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) {
} }
} }
func TestHelperInit(t *testing.T) { func overridePath(t *testing.T) {
if len(os.Args) != 5 || os.Args[4] != "init" { proxyName := dbus.ProxyName
return dbus.ProxyName = "/nonexistent-xdg-dbus-proxy"
} t.Cleanup(func() {
sandbox.SetOutput(fmsg.Output{}) dbus.ProxyName = proxyName
sandbox.Init(fmsg.Prepare, internal.InstallFmsg) })
} }

View File

@ -1,178 +0,0 @@
package dbus
import (
"context"
"errors"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/ldd"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
)
// Start launches the D-Bus proxy.
func (p *Proxy) Start(ctx context.Context, output io.Writer, useSandbox bool) error {
p.lock.Lock()
defer p.lock.Unlock()
if p.seal == nil {
return errors.New("proxy not sealed")
}
var h helper.Helper
c, cancel := context.WithCancelCause(ctx)
if !useSandbox {
h = helper.NewDirect(c, p.name, p.seal, true, argF, func(cmd *exec.Cmd) {
if p.CmdF != nil {
p.CmdF(cmd)
}
if output != nil {
cmd.Stdout, cmd.Stderr = output, output
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Env = make([]string, 0)
}, nil)
} else {
toolPath := p.name
if filepath.Base(p.name) == p.name {
if s, err := exec.LookPath(p.name); err != nil {
return err
} else {
toolPath = s
}
}
var libPaths []string
if entries, err := ldd.ExecFilter(ctx, p.CommandContext, p.FilterF, toolPath); err != nil {
return err
} else {
libPaths = ldd.Path(entries)
}
h = helper.New(
c, toolPath,
p.seal, true,
argF, func(container *sandbox.Container) {
container.Seccomp |= seccomp.FlagMultiarch
container.Hostname = "fortify-dbus"
container.CommandContext = p.CommandContext
if output != nil {
container.Stdout, container.Stderr = output, output
}
if p.CmdF != nil {
p.CmdF(container)
}
// these lib paths are unpredictable, so mount them first so they cannot cover anything
for _, name := range libPaths {
container.Bind(name, name, 0)
}
// upstream bus directories
upstreamPaths := make([]string, 0, 2)
for _, as := range []string{p.session[0], p.system[0]} {
if len(as) > 0 && strings.HasPrefix(as, "unix:path=/") {
// leave / intact
upstreamPaths = append(upstreamPaths, path.Dir(as[10:]))
}
}
slices.Sort(upstreamPaths)
upstreamPaths = slices.Compact(upstreamPaths)
for _, name := range upstreamPaths {
container.Bind(name, name, 0)
}
// parent directories of bind paths
sockDirPaths := make([]string, 0, 2)
if d := path.Dir(p.session[1]); path.IsAbs(d) {
sockDirPaths = append(sockDirPaths, d)
}
if d := path.Dir(p.system[1]); path.IsAbs(d) {
sockDirPaths = append(sockDirPaths, d)
}
slices.Sort(sockDirPaths)
sockDirPaths = slices.Compact(sockDirPaths)
for _, name := range sockDirPaths {
container.Bind(name, name, sandbox.BindWritable)
}
// xdg-dbus-proxy bin path
binPath := path.Dir(toolPath)
container.Bind(binPath, binPath, 0)
}, nil)
}
if err := h.Start(); err != nil {
cancel(err)
return err
}
p.helper = h
p.ctx = c
p.cancel = cancel
return nil
}
var proxyClosed = errors.New("proxy closed")
// Wait blocks until xdg-dbus-proxy exits and releases resources.
func (p *Proxy) Wait() error {
p.lock.RLock()
defer p.lock.RUnlock()
if p.helper == nil {
return errors.New("dbus: not started")
}
errs := make([]error, 3)
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
if err := os.Remove(p.session[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
errs[1] = err
}
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
}
func argF(argsFd, statFd int) []string {
if statFd == -1 {
return []string{"--args=" + strconv.Itoa(argsFd)}
} else {
return []string{"--args=" + strconv.Itoa(argsFd), "--fd=" + strconv.Itoa(statFd)}
}
}

View File

@ -5,10 +5,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"os/exec"
"sync" "sync"
"git.gensokyo.uk/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/helper/bwrap"
) )
// ProxyName is the file name or path to the proxy program. // ProxyName is the file name or path to the proxy program.
@ -19,18 +19,15 @@ var ProxyName = "xdg-dbus-proxy"
// Once sealed, configuration changes will no longer be possible and attempting to do so will result in a panic. // Once sealed, configuration changes will no longer be possible and attempting to do so will result in a panic.
type Proxy struct { type Proxy struct {
helper helper.Helper helper helper.Helper
bwrap *bwrap.Config
ctx context.Context ctx context.Context
cancel context.CancelCauseFunc cancel context.CancelCauseFunc
name string name string
session [2]string session [2]string
system [2]string system [2]string
CmdF func(any)
sysP bool sysP bool
CommandContext func(ctx context.Context) (cmd *exec.Cmd)
FilterF func([]byte) []byte
seal io.WriterTo seal io.WriterTo
lock sync.RWMutex lock sync.RWMutex
} }

175
dbus/run.go Normal file
View File

@ -0,0 +1,175 @@
package dbus
import (
"context"
"errors"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"
"git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/ldd"
)
// Start launches the D-Bus proxy.
func (p *Proxy) Start(ctx context.Context, output io.Writer, sandbox bool) error {
p.lock.Lock()
defer p.lock.Unlock()
if p.seal == nil {
return errors.New("proxy not sealed")
}
var (
h helper.Helper
argF = func(argsFD, statFD int) []string {
if statFD == -1 {
return []string{"--args=" + strconv.Itoa(argsFD)}
} else {
return []string{"--args=" + strconv.Itoa(argsFD), "--fd=" + strconv.Itoa(statFD)}
}
}
)
if !sandbox {
h = helper.New(p.seal, p.name, argF)
// xdg-dbus-proxy does not need to inherit the environment
h.SetEnv(make([]string, 0))
} else {
// look up absolute path if name is just a file name
toolPath := p.name
if filepath.Base(p.name) == p.name {
if s, err := exec.LookPath(p.name); err != nil {
return err
} else {
toolPath = s
}
}
// resolve libraries by parsing ldd output
var proxyDeps []*ldd.Entry
if toolPath != "/nonexistent-xdg-dbus-proxy" {
if l, err := ldd.Exec(ctx, toolPath); err != nil {
return err
} else {
proxyDeps = l
}
}
bc := &bwrap.Config{
Unshare: nil,
Hostname: "fortify-dbus",
Chdir: "/",
Syscall: &bwrap.SyscallPolicy{DenyDevel: true, Multiarch: true},
Clearenv: true,
NewSession: true,
DieWithParent: true,
}
// resolve proxy socket directories
bindTarget := make(map[string]struct{}, 2)
for _, ps := range []string{p.session[1], p.system[1]} {
if pd := path.Dir(ps); len(pd) > 0 {
if pd[0] == '/' {
bindTarget[pd] = struct{}{}
}
}
}
for k := range bindTarget {
bc.Bind(k, k, false, true)
}
roBindTarget := make(map[string]struct{}, 2+1+len(proxyDeps))
// xdb-dbus-proxy bin and dependencies
roBindTarget[path.Dir(toolPath)] = struct{}{}
for _, ent := range proxyDeps {
if path.IsAbs(ent.Path) {
roBindTarget[path.Dir(ent.Path)] = struct{}{}
}
if path.IsAbs(ent.Name) {
roBindTarget[path.Dir(ent.Name)] = struct{}{}
}
}
// resolve upstream bus directories
for _, as := range []string{p.session[0], p.system[0]} {
if len(as) > 0 && strings.HasPrefix(as, "unix:path=/") {
// leave / intact
roBindTarget[path.Dir(as[10:])] = struct{}{}
}
}
for k := range roBindTarget {
bc.Bind(k, k)
}
h = helper.MustNewBwrap(bc, toolPath, p.seal, argF, nil, nil)
p.bwrap = bc
}
if output != nil {
h.Stdout(output).Stderr(output)
}
c, cancel := context.WithCancelCause(ctx)
if err := h.Start(c, true); err != nil {
cancel(err)
return err
}
p.helper = h
p.ctx = c
p.cancel = cancel
return nil
}
var proxyClosed = errors.New("proxy closed")
// Wait blocks until xdg-dbus-proxy exits and releases resources.
func (p *Proxy) Wait() error {
p.lock.RLock()
defer p.lock.RUnlock()
if p.helper == nil {
return errors.New("dbus: not started")
}
errs := make([]error, 3)
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
if err := os.Remove(p.session[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
errs[1] = err
}
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
}

View File

@ -6,12 +6,6 @@ import (
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
) )
const (
sampleHostPath = "/tmp/bus"
sampleHostAddr = "unix:path=" + sampleHostPath
sampleBindPath = "/tmp/proxied_bus"
)
var samples = []dbusTestCase{ var samples = []dbusTestCase{
{ {
"org.chromium.Chromium", &dbus.Config{ "org.chromium.Chromium", &dbus.Config{
@ -25,10 +19,10 @@ var samples = []dbusTestCase{
Log: false, Log: false,
Filter: true, Filter: true,
}, false, false, }, false, false,
[2]string{sampleHostAddr, sampleBindPath}, [2]string{"unix:path=/run/user/1971/bus", "/tmp/fortify.1971/12622d846cc3fe7b4c10359d01f0eb47/bus"},
[]string{ []string{
sampleHostAddr, "unix:path=/run/user/1971/bus",
sampleBindPath, "/tmp/fortify.1971/12622d846cc3fe7b4c10359d01f0eb47/bus",
"--filter", "--filter",
"--talk=org.freedesktop.Notifications", "--talk=org.freedesktop.Notifications",
"--talk=org.freedesktop.FileManager1", "--talk=org.freedesktop.FileManager1",
@ -54,10 +48,9 @@ var samples = []dbusTestCase{
Log: false, Log: false,
Filter: true, Filter: true,
}, false, false, }, false, false,
[2]string{sampleHostAddr, sampleBindPath}, [2]string{"unix:path=/run/dbus/system_bus_socket", "/tmp/fortify.1971/12622d846cc3fe7b4c10359d01f0eb47/system_bus_socket"},
[]string{ []string{"unix:path=/run/dbus/system_bus_socket",
sampleHostAddr, "/tmp/fortify.1971/12622d846cc3fe7b4c10359d01f0eb47/system_bus_socket",
sampleBindPath,
"--filter", "--filter",
"--talk=org.bluez", "--talk=org.bluez",
"--talk=org.freedesktop.Avahi", "--talk=org.freedesktop.Avahi",
@ -75,10 +68,10 @@ var samples = []dbusTestCase{
Log: false, Log: false,
Filter: true, Filter: true,
}, false, false, }, false, false,
[2]string{sampleHostAddr, sampleBindPath}, [2]string{"unix:path=/run/user/1971/bus", "/tmp/fortify.1971/34c24f16a0d791d28835ededaf446033/bus"},
[]string{ []string{
sampleHostAddr, "unix:path=/run/user/1971/bus",
sampleBindPath, "/tmp/fortify.1971/34c24f16a0d791d28835ededaf446033/bus",
"--filter", "--filter",
"--talk=org.freedesktop.Notifications", "--talk=org.freedesktop.Notifications",
"--talk=org.kde.StatusNotifierWatcher", "--talk=org.kde.StatusNotifierWatcher",
@ -98,10 +91,10 @@ var samples = []dbusTestCase{
Log: true, Log: true,
Filter: true, Filter: true,
}, false, false, }, false, false,
[2]string{sampleHostAddr, sampleBindPath}, [2]string{"unix:path=/run/user/1971/bus", "/tmp/fortify.1971/5da7845287a936efbc2fa75d7d81e501/bus"},
[]string{ []string{
sampleHostAddr, "unix:path=/run/user/1971/bus",
sampleBindPath, "/tmp/fortify.1971/5da7845287a936efbc2fa75d7d81e501/bus",
"--filter", "--filter",
"--see=uk.gensokyo.CrashTestDummy1", "--see=uk.gensokyo.CrashTestDummy1",
"--talk=org.freedesktop.Notifications", "--talk=org.freedesktop.Notifications",
@ -121,10 +114,10 @@ var samples = []dbusTestCase{
Log: true, Log: true,
Filter: true, Filter: true,
}, false, true, }, false, true,
[2]string{sampleHostAddr, sampleBindPath}, [2]string{"unix:path=/run/user/1971/bus", "/tmp/fortify.1971/5da7845287a936efbc2fa75d7d81e501/bus"},
[]string{ []string{
sampleHostAddr, "unix:path=/run/user/1971/bus",
sampleBindPath, "/tmp/fortify.1971/5da7845287a936efbc2fa75d7d81e501/bus",
"--filter", "--filter",
"--see=uk.gensokyo.CrashTestDummy", "--see=uk.gensokyo.CrashTestDummy",
"--talk=org.freedesktop.Notifications", "--talk=org.freedesktop.Notifications",

View File

@ -6,4 +6,6 @@ import (
"git.gensokyo.uk/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
) )
func TestHelperStub(t *testing.T) { helper.InternalHelperStub() } func TestHelperChildStub(t *testing.T) {
helper.InternalChildStub()
}

7
dist/release.sh vendored
View File

@ -10,10 +10,9 @@ cp -rv "comp" "${out}"
go generate ./... go generate ./...
go build -trimpath -v -o "${out}/bin/" -ldflags "-s -w -buildid= -extldflags '-static' go build -trimpath -v -o "${out}/bin/" -ldflags "-s -w -buildid= -extldflags '-static'
-X git.gensokyo.uk/security/fortify/internal.version=${VERSION} -X git.gensokyo.uk/security/fortify/internal.Version=${VERSION}
-X git.gensokyo.uk/security/fortify/internal.fsu=/usr/bin/fsu -X git.gensokyo.uk/security/fortify/internal.Fsu=/usr/bin/fsu
-X main.fmain=/usr/bin/fortify -X main.Fmain=/usr/bin/fortify" ./...
-X main.fpkg=/usr/bin/fpkg" ./...
rm -f "./${out}.tar.gz" && tar -C dist -czf "${out}.tar.gz" "${pname}" rm -f "./${out}.tar.gz" && tar -C dist -czf "${out}.tar.gz" "${pname}"
rm -rf "./${out}" rm -rf "./${out}"

46
error.go Normal file
View File

@ -0,0 +1,46 @@
package main
import (
"errors"
"log"
"git.gensokyo.uk/security/fortify/internal/app"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
func logWaitError(err error) {
var e *fmsg.BaseError
if !fmsg.AsBaseError(err, &e) {
log.Println("wait failed:", err)
} else {
// Wait only returns either *app.ProcessError or *app.StateStoreError wrapped in a *app.BaseError
var se *app.StateStoreError
if !errors.As(err, &se) {
// does not need special handling
log.Print(e.Message())
} else {
// inner error are either unwrapped store errors
// or joined errors returned by *appSealTx revert
// wrapped in *app.BaseError
var ej app.RevertCompoundError
if !errors.As(se.InnerErr, &ej) {
// does not require special handling
log.Print(e.Message())
} else {
errs := ej.Unwrap()
// every error here is wrapped in *app.BaseError
for _, ei := range errs {
var eb *fmsg.BaseError
if !errors.As(ei, &eb) {
// unreachable
log.Println("invalid error type returned by revert:", ei)
} else {
// print inner *app.BaseError message
log.Print(eb.Message())
}
}
}
}
}
}

14
flake.lock generated
View File

@ -7,11 +7,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1742234739, "lastModified": 1736373539,
"narHash": "sha256-zFL6zsf/5OztR1NSNQF33dvS1fL/BzVUjabZq4qrtY4=", "narHash": "sha256-dinzAqCjenWDxuy+MqUQq0I4zUSfaCvN9rzuCmgMZJY=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "f6af7280a3390e65c2ad8fd059cdc303426cbd59", "rev": "bd65bc3cde04c16755955630b344bc9e35272c56",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -23,16 +23,16 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1742512142, "lastModified": 1739333913,
"narHash": "sha256-8XfURTDxOm6+33swQJu/hx6xw1Tznl8vJJN5HwVqckg=", "narHash": "sha256-JXt5FtySR+yBm5ny8zG/hX1IybF/7R66jZfXxXSb6wY=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "7105ae3957700a9646cc4b766f5815b23ed0c682", "rev": "7d83f668aee9e41d574c398a9bb569047e8a3f5d",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-24.11", "ref": "nixos-24.11-small",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }

122
flake.nix
View File

@ -2,7 +2,7 @@
description = "fortify sandbox tool and nixos module"; description = "fortify sandbox tool and nixos module";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11-small";
home-manager = { home-manager = {
url = "github:nix-community/home-manager/release-24.11"; url = "github:nix-community/home-manager/release-24.11";
@ -27,7 +27,7 @@
nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
in in
{ {
nixosModules.fortify = import ./nixos.nix self.packages; nixosModules.fortify = import ./nixos.nix;
buildPackage = forAllSystems ( buildPackage = forAllSystems (
system: system:
@ -58,7 +58,6 @@
in in
{ {
fortify = callPackage ./test { inherit system self; }; fortify = callPackage ./test { inherit system self; };
fpkg = callPackage ./cmd/fpkg/test { inherit system self; };
race = callPackage ./test { race = callPackage ./test {
inherit system self; inherit system self;
withRace = true; withRace = true;
@ -68,7 +67,7 @@
cd ${./.} cd ${./.}
echo "running nixfmt..." echo "running nixfmt..."
nixfmt --width=256 --check . nixfmt --check .
touch $out touch $out
''; '';
@ -98,62 +97,115 @@
packages = forAllSystems ( packages = forAllSystems (
system: system:
let let
inherit (self.packages.${system}) fortify fsu; inherit (self.packages.${system}) fortify;
pkgs = nixpkgsFor.${system}; pkgs = nixpkgsFor.${system};
in in
{ {
default = fortify; default = self.packages.${system}.fortify;
fortify = pkgs.pkgsStatic.callPackage ./package.nix { fortify = pkgs.pkgsStatic.callPackage ./package.nix {
inherit (pkgs) inherit (pkgs) bubblewrap xdg-dbus-proxy glibc;
# passthru.buildInputs
go
gcc
# nativeBuildInputs
pkg-config
wayland-scanner
makeBinaryWrapper
# appPackages
glibc
xdg-dbus-proxy
# fpkg
zstd
gnutar
coreutils
;
}; };
fsu = pkgs.callPackage ./cmd/fsu/package.nix { inherit (self.packages.${system}) fortify; }; fsu = pkgs.callPackage ./cmd/fsu/package.nix { inherit (self.packages.${system}) fortify; };
dist = pkgs.runCommand "${fortify.name}-dist" { buildInputs = fortify.targetPkgs ++ [ pkgs.pkgsStatic.musl ]; } '' dist =
pkgs.runCommand "${fortify.name}-dist" { inherit (self.devShells.${system}.default) buildInputs; }
''
# go requires XDG_CACHE_HOME for the build cache # go requires XDG_CACHE_HOME for the build cache
export XDG_CACHE_HOME="$(mktemp -d)" export XDG_CACHE_HOME="$(mktemp -d)"
# get a different workdir as go does not like /build # get a different workdir as go does not like /build
cd $(mktemp -d) \ cd $(mktemp -d) && cp -r ${fortify.src}/. . && chmod -R +w .
&& cp -r ${fortify.src}/. . \
&& chmod +w cmd && cp -r ${fsu.src}/. cmd/fsu/ \
&& chmod -R +w .
export FORTIFY_VERSION="v${fortify.version}" export FORTIFY_VERSION="v${fortify.version}"
./dist/release.sh && mkdir $out && cp -v "dist/fortify-$FORTIFY_VERSION.tar.gz"* $out ./dist/release.sh && mkdir $out && cp -v "dist/fortify-$FORTIFY_VERSION.tar.gz"* $out
''; '';
fhs = pkgs.buildFHSEnv {
pname = "fortify-fhs";
inherit (fortify) version;
targetPkgs =
pkgs:
with pkgs;
[
go
gcc
pkg-config
wayland-scanner
]
++ (
with pkgs.pkgsStatic;
[
musl
libffi
libseccomp
acl
wayland
wayland-protocols
]
++ (with xorg; [
libxcb
libXau
libXdmcp
xorgproto
])
);
extraOutputsToInstall = [ "dev" ];
profile = ''
export PKG_CONFIG_PATH="/usr/share/pkgconfig:$PKG_CONFIG_PATH"
'';
};
} }
); );
devShells = forAllSystems ( devShells = forAllSystems (
system: system:
let let
inherit (self.packages.${system}) fortify; inherit (self.packages.${system}) fortify fhs;
pkgs = nixpkgsFor.${system}; pkgs = nixpkgsFor.${system};
in in
{ {
default = pkgs.mkShell { buildInputs = fortify.targetPkgs; }; default = pkgs.mkShell {
withPackage = pkgs.mkShell { buildInputs = [ fortify ] ++ fortify.targetPkgs; }; buildInputs =
with pkgs;
[
go
gcc
]
# buildInputs
++ (
with pkgsStatic;
[
musl
libffi
libseccomp
acl
wayland
wayland-protocols
]
++ (with xorg; [
libxcb
libXau
libXdmcp
])
)
# nativeBuildInputs
++ [
pkg-config
wayland-scanner
makeBinaryWrapper
];
};
fhs = fhs.env;
withPackage = nixpkgsFor.${system}.mkShell {
buildInputs = [ self.packages.${system}.fortify ] ++ self.devShells.${system}.default.buildInputs;
};
generateDoc = generateDoc =
let let
pkgs = nixpkgsFor.${system};
inherit (pkgs) lib; inherit (pkgs) lib;
doc = doc =
@ -162,7 +214,7 @@
specialArgs = { specialArgs = {
inherit pkgs; inherit pkgs;
}; };
modules = [ (import ./options.nix self.packages) ]; modules = [ ./options.nix ];
}; };
cleanEval = lib.filterAttrsRecursive (n: _: n != "_module") eval; cleanEval = lib.filterAttrsRecursive (n: _: n != "_module") eval;
in in
@ -172,7 +224,7 @@
sed -i '/*Declared by:*/,+1 d' $out sed -i '/*Declared by:*/,+1 d' $out
''; '';
in in
pkgs.mkShell { nixpkgsFor.${system}.mkShell {
shellHook = '' shellHook = ''
exec cat ${docText} > options.md exec cat ${docText} > options.md
''; '';

View File

@ -2,6 +2,7 @@
package fst package fst
import ( import (
"context"
"time" "time"
) )
@ -18,7 +19,7 @@ type App interface {
type SealedApp interface { type SealedApp interface {
// Run commits sealed system setup and starts the app process. // Run commits sealed system setup and starts the app process.
Run(rs *RunState) error Run(ctx context.Context, rs *RunState) error
} }
// RunState stores the outcome of a call to [SealedApp.Run]. // RunState stores the outcome of a call to [SealedApp.Run].

View File

@ -2,7 +2,7 @@ package fst
import ( import (
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/sandbox/seccomp" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/system" "git.gensokyo.uk/security/fortify/system"
) )
@ -14,11 +14,8 @@ type Config struct {
// passed to wayland security-context-v1 as application ID // passed to wayland security-context-v1 as application ID
// and used as part of defaults in dbus session proxy // and used as part of defaults in dbus session proxy
ID string `json:"id"` ID string `json:"id"`
// final argv, passed to init
// absolute path to executable file Command []string `json:"command"`
Path string `json:"path,omitempty"`
// final args passed to container init
Args []string `json:"args"`
Confinement ConfinementConfig `json:"confinement"` Confinement ConfinementConfig `json:"confinement"`
} }
@ -29,13 +26,13 @@ type ConfinementConfig struct {
AppID int `json:"app_id"` AppID int `json:"app_id"`
// list of supplementary groups to inherit // list of supplementary groups to inherit
Groups []string `json:"groups"` Groups []string `json:"groups"`
// passwd username in container, defaults to passwd name of target uid or chronos // passwd username in the sandbox, defaults to passwd name of target uid or chronos
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
// home directory in container, empty for outer // home directory in sandbox, empty for outer
Inner string `json:"home_inner"` Inner string `json:"home_inner"`
// home directory in init namespace // home directory in init namespace
Outer string `json:"home"` Outer string `json:"home"`
// abstract sandbox configuration // bwrap sandbox confinement configuration
Sandbox *SandboxConfig `json:"sandbox"` Sandbox *SandboxConfig `json:"sandbox"`
// extra acl ops, runs after everything else // extra acl ops, runs after everything else
ExtraPerms []*ExtraPermConfig `json:"extra_perms,omitempty"` ExtraPerms []*ExtraPermConfig `json:"extra_perms,omitempty"`
@ -47,8 +44,8 @@ type ConfinementConfig struct {
// nil value makes session bus proxy assume built-in defaults // nil value makes session bus proxy assume built-in defaults
SessionBus *dbus.Config `json:"session_bus,omitempty"` SessionBus *dbus.Config `json:"session_bus,omitempty"`
// system resources to expose to the container // system resources to expose to the sandbox
Enablements system.Enablement `json:"enablements"` Enablements system.Enablements `json:"enablements"`
} }
type ExtraPermConfig struct { type ExtraPermConfig struct {
@ -79,12 +76,24 @@ func (e *ExtraPermConfig) String() string {
return string(buf) return string(buf)
} }
type FilesystemConfig struct {
// mount point in sandbox, same as src if empty
Dst string `json:"dst,omitempty"`
// host filesystem path to make available to sandbox
Src string `json:"src"`
// write access
Write bool `json:"write,omitempty"`
// device access
Device bool `json:"dev,omitempty"`
// fail if mount fails
Must bool `json:"require,omitempty"`
}
// Template returns a fully populated instance of Config. // Template returns a fully populated instance of Config.
func Template() *Config { func Template() *Config {
return &Config{ return &Config{
ID: "org.chromium.Chromium", ID: "org.chromium.Chromium",
Path: "/run/current-system/sw/bin/chromium", Command: []string{
Args: []string{
"chromium", "chromium",
"--ignore-gpu-blocklist", "--ignore-gpu-blocklist",
"--disable-smooth-scrolling", "--disable-smooth-scrolling",
@ -99,13 +108,11 @@ func Template() *Config {
Inner: "/var/lib/fortify", Inner: "/var/lib/fortify",
Sandbox: &SandboxConfig{ Sandbox: &SandboxConfig{
Hostname: "localhost", Hostname: "localhost",
Devel: true, UserNS: true,
Userns: true,
Net: true, Net: true,
Dev: true, Dev: true,
Seccomp: seccomp.FlagMultiarch, Syscall: &bwrap.SyscallPolicy{DenyDevel: true, Multiarch: true},
Tty: true, NoNewSession: true,
Multiarch: true,
MapRealUID: true, MapRealUID: true,
DirectWayland: false, DirectWayland: false,
// example API credentials pulled from Google Chrome // example API credentials pulled from Google Chrome
@ -127,7 +134,7 @@ func Template() *Config {
Link: [][2]string{{"/run/user/65534", "/run/user/150"}}, Link: [][2]string{{"/run/user/65534", "/run/user/150"}},
Etc: "/etc", Etc: "/etc",
AutoEtc: true, AutoEtc: true,
Cover: []string{"/var/run/nscd"}, Override: []string{"/var/run/nscd"},
}, },
ExtraPerms: []*ExtraPermConfig{ ExtraPerms: []*ExtraPermConfig{
{Path: "/var/lib/fortify/u0", Ensure: true, Execute: true}, {Path: "/var/lib/fortify/u0", Ensure: true, Execute: true},
@ -153,7 +160,7 @@ func Template() *Config {
Log: false, Log: false,
Filter: true, Filter: true,
}, },
Enablements: system.EWayland | system.EDBus | system.EPulse, Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(),
}, },
} }
} }

View File

@ -4,149 +4,125 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"maps"
"path" "path"
"slices"
"syscall"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/sandbox" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
) )
// SandboxConfig describes resources made available to the sandbox. // SandboxConfig describes resources made available to the sandbox.
type ( type SandboxConfig struct {
SandboxConfig struct { // unix hostname within sandbox
// container hostname
Hostname string `json:"hostname,omitempty"` Hostname string `json:"hostname,omitempty"`
// allow userns within sandbox
// extra seccomp flags UserNS bool `json:"userns,omitempty"`
Seccomp seccomp.SyscallOpts `json:"seccomp"` // share net namespace
// allow ptrace and friends
Devel bool `json:"devel,omitempty"`
// allow userns creation in container
Userns bool `json:"userns,omitempty"`
// share host net namespace
Net bool `json:"net,omitempty"` Net bool `json:"net,omitempty"`
// expose main process tty // share all devices
Tty bool `json:"tty,omitempty"` Dev bool `json:"dev,omitempty"`
// allow multiarch // seccomp syscall filter policy
Multiarch bool `json:"multiarch,omitempty"` Syscall *bwrap.SyscallPolicy `json:"syscall"`
// do not run in new session
// initial process environment variables NoNewSession bool `json:"no_new_session,omitempty"`
Env map[string]string `json:"env"`
// map target user uid to privileged user uid in the user namespace // map target user uid to privileged user uid in the user namespace
MapRealUID bool `json:"map_real_uid"` MapRealUID bool `json:"map_real_uid"`
// expose all devices
Dev bool `json:"dev,omitempty"`
// container host filesystem bind mounts
Filesystem []*FilesystemConfig `json:"filesystem"`
// create symlinks inside container filesystem
Link [][2]string `json:"symlink"`
// direct access to wayland socket; when this gets set no attempt is made to attach security-context-v1 // direct access to wayland socket; when this gets set no attempt is made to attach security-context-v1
// and the bare socket is mounted to the sandbox // and the bare socket is mounted to the sandbox
DirectWayland bool `json:"direct_wayland,omitempty"` DirectWayland bool `json:"direct_wayland,omitempty"`
// final environment variables
Env map[string]string `json:"env"`
// sandbox host filesystem access
Filesystem []*FilesystemConfig `json:"filesystem"`
// symlinks created inside the sandbox
Link [][2]string `json:"symlink"`
// read-only /etc directory // read-only /etc directory
Etc string `json:"etc,omitempty"` Etc string `json:"etc,omitempty"`
// automatically set up /etc symlinks // automatically set up /etc symlinks
AutoEtc bool `json:"auto_etc"` AutoEtc bool `json:"auto_etc"`
// cover these paths or create them if they do not already exist // mount tmpfs over these paths,
Cover []string `json:"cover"` // runs right before [ConfinementConfig.ExtraPerms]
} Override []string `json:"override"`
}
// SandboxSys encapsulates system functions used during [sandbox.Container] initialisation. // SandboxSys encapsulates system functions used during the creation of [bwrap.Config].
SandboxSys interface { type SandboxSys interface {
Getuid() int Geteuid() int
Getgid() int
Paths() Paths Paths() Paths
ReadDir(name string) ([]fs.DirEntry, error) ReadDir(name string) ([]fs.DirEntry, error)
EvalSymlinks(path string) (string, error) EvalSymlinks(path string) (string, error)
Println(v ...any) Println(v ...any)
Printf(format string, v ...any) Printf(format string, v ...any)
} }
// FilesystemConfig is a representation of [sandbox.BindMount]. // Bwrap returns the address of the corresponding bwrap.Config to s.
FilesystemConfig struct { // Note that remaining tmpfs entries must be queued by the caller prior to launch.
// mount point in container, same as src if empty func (s *SandboxConfig) Bwrap(sys SandboxSys, uid *int) (*bwrap.Config, error) {
Dst string `json:"dst,omitempty"`
// host filesystem path to make available to the container
Src string `json:"src"`
// do not mount filesystem read-only
Write bool `json:"write,omitempty"`
// do not disable device files
Device bool `json:"dev,omitempty"`
// fail if the bind mount cannot be established for any reason
Must bool `json:"require,omitempty"`
}
)
// ToContainer initialises [sandbox.Params] via [SandboxConfig].
// Note that remaining container setup must be queued by the [App] implementation.
func (s *SandboxConfig) ToContainer(sys SandboxSys, uid, gid *int) (*sandbox.Params, map[string]string, error) {
if s == nil { if s == nil {
return nil, nil, syscall.EBADE return nil, errors.New("nil sandbox config")
} }
container := &sandbox.Params{ if s.Syscall == nil {
Hostname: s.Hostname, sys.Println("syscall filter not configured, PROCEED WITH CAUTION")
Ops: new(sandbox.Ops),
Seccomp: s.Seccomp,
} }
if !s.MapRealUID {
// mapped uid defaults to 65534 to work around file ownership checks due to a bwrap limitation
*uid = 65534
} else {
// some programs fail to connect to dbus session running as a different uid, so a separate workaround
// is introduced to map priv-side caller uid in namespace
*uid = sys.Geteuid()
}
conf := (&bwrap.Config{
Net: s.Net,
UserNS: s.UserNS,
UID: uid,
GID: uid,
Hostname: s.Hostname,
Clearenv: true,
SetEnv: s.Env,
/* this is only 4 KiB of memory on a 64-bit system, /* this is only 4 KiB of memory on a 64-bit system,
permissive defaults on NixOS results in around 100 entries permissive defaults on NixOS results in around 100 entries
so this capacity should eliminate copies for most setups */ so this capacity should eliminate copies for most setups */
*container.Ops = slices.Grow(*container.Ops, 1<<8) Filesystem: make([]bwrap.FSBuilder, 0, 256),
if s.Devel { Syscall: s.Syscall,
container.Flags |= sandbox.FAllowDevel NewSession: !s.NoNewSession,
} DieWithParent: true,
if s.Userns { AsInit: true,
container.Flags |= sandbox.FAllowUserns
}
if s.Net {
container.Flags |= sandbox.FAllowNet
}
if s.Tty {
container.Flags |= sandbox.FAllowTTY
}
if s.MapRealUID { // initialise unconditionally as Once cannot be justified
/* some programs fail to connect to dbus session running as a different uid // for saving such a miniscule amount of memory
so this workaround is introduced to map priv-side caller uid in container */ Chmod: make(bwrap.ChmodConfig),
container.Uid = sys.Getuid() }).
*uid = container.Uid Procfs("/proc").
container.Gid = sys.Getgid() Tmpfs(Tmp, 4*1024)
*gid = container.Gid
} else {
*uid = sandbox.OverflowUid()
*gid = sandbox.OverflowGid()
}
container.
Proc("/proc").
Tmpfs(Tmp, 1<<12, 0755)
if !s.Dev { if !s.Dev {
container.Dev("/dev").Mqueue("/dev/mqueue") conf.DevTmpfs("/dev").Mqueue("/dev/mqueue")
} else { } else {
container.Bind("/dev", "/dev", sandbox.BindDevice) conf.Bind("/dev", "/dev", false, true, true)
} }
/* retrieve paths and hide them if they're made available in the sandbox; if !s.AutoEtc {
this feature tries to improve user experience of permissive defaults, and if s.Etc == "" {
to warn about issues in custom configuration; it is NOT a security feature conf.Dir("/etc")
and should not be treated as such, ALWAYS be careful with what you bind */ } else {
conf.Bind(s.Etc, "/etc")
}
}
// retrieve paths and hide them if they're made available in the sandbox
var hidePaths []string var hidePaths []string
sc := sys.Paths() sc := sys.Paths()
hidePaths = append(hidePaths, sc.RuntimePath, sc.SharePath) hidePaths = append(hidePaths, sc.RuntimePath, sc.SharePath)
_, systemBusAddr := dbus.Address() _, systemBusAddr := dbus.Address()
if entries, err := dbus.Parse([]byte(systemBusAddr)); err != nil { if entries, err := dbus.Parse([]byte(systemBusAddr)); err != nil {
return nil, nil, err return nil, err
} else { } else {
// there is usually only one, do not preallocate // there is usually only one, do not preallocate
for _, entry := range entries { for _, entry := range entries {
@ -172,7 +148,7 @@ func (s *SandboxConfig) ToContainer(sys SandboxSys, uid, gid *int) (*sandbox.Par
hidePathMatch := make([]bool, len(hidePaths)) hidePathMatch := make([]bool, len(hidePaths))
for i := range hidePaths { for i := range hidePaths {
if err := evalSymlinks(sys, &hidePaths[i]); err != nil { if err := evalSymlinks(sys, &hidePaths[i]); err != nil {
return nil, nil, err return nil, err
} }
} }
@ -182,19 +158,19 @@ func (s *SandboxConfig) ToContainer(sys SandboxSys, uid, gid *int) (*sandbox.Par
} }
if !path.IsAbs(c.Src) { if !path.IsAbs(c.Src) {
return nil, nil, fmt.Errorf("src path %q is not absolute", c.Src) return nil, fmt.Errorf("src path %q is not absolute", c.Src)
} }
dest := c.Dst dest := c.Dst
if c.Dst == "" { if c.Dst == "" {
dest = c.Src dest = c.Src
} else if !path.IsAbs(dest) { } else if !path.IsAbs(dest) {
return nil, nil, fmt.Errorf("dst path %q is not absolute", dest) return nil, fmt.Errorf("dst path %q is not absolute", dest)
} }
srcH := c.Src srcH := c.Src
if err := evalSymlinks(sys, &srcH); err != nil { if err := evalSymlinks(sys, &srcH); err != nil {
return nil, nil, err return nil, err
} }
for i := range hidePaths { for i := range hidePaths {
@ -204,69 +180,54 @@ func (s *SandboxConfig) ToContainer(sys SandboxSys, uid, gid *int) (*sandbox.Par
} }
if ok, err := deepContainsH(srcH, hidePaths[i]); err != nil { if ok, err := deepContainsH(srcH, hidePaths[i]); err != nil {
return nil, nil, err return nil, err
} else if ok { } else if ok {
hidePathMatch[i] = true hidePathMatch[i] = true
sys.Printf("hiding paths from %q", c.Src) sys.Printf("hiding paths from %q", c.Src)
} }
} }
var flags int conf.Bind(c.Src, dest, !c.Must, c.Write, c.Device)
if c.Write {
flags |= sandbox.BindWritable
}
if c.Device {
flags |= sandbox.BindDevice | sandbox.BindWritable
}
if !c.Must {
flags |= sandbox.BindOptional
}
container.Bind(c.Src, dest, flags)
} }
// cover matched paths // hide marked paths before setting up shares
for i, ok := range hidePathMatch { for i, ok := range hidePathMatch {
if ok { if ok {
container.Tmpfs(hidePaths[i], 1<<13, 0755) conf.Tmpfs(hidePaths[i], 8192)
} }
} }
for _, l := range s.Link { for _, l := range s.Link {
container.Link(l[0], l[1]) conf.Symlink(l[0], l[1])
} }
// perf: this might work better if implemented as a setup op in container init if s.AutoEtc {
if !s.AutoEtc { etc := s.Etc
if s.Etc != "" { if etc == "" {
container.Bind(s.Etc, "/etc", 0) etc = "/etc"
} }
} else { conf.Bind(etc, Tmp+"/etc")
etcPath := s.Etc
if etcPath == "" {
etcPath = "/etc"
}
container.Bind(etcPath, Tmp+"/etc", 0)
// link host /etc contents to prevent dropping passwd/group bind mounts // link host /etc contents to prevent passwd/group from being overwritten
if d, err := sys.ReadDir(etcPath); err != nil { if d, err := sys.ReadDir(etc); err != nil {
return nil, nil, err return nil, err
} else { } else {
for _, ent := range d { for _, ent := range d {
n := ent.Name() name := ent.Name()
switch n { switch name {
case "passwd": case "passwd":
case "group": case "group":
case "mtab": case "mtab":
container.Link("/proc/mounts", "/etc/"+n) conf.Symlink("/proc/mounts", "/etc/"+name)
default: default:
container.Link(Tmp+"/etc/"+n, "/etc/"+n) conf.Symlink(Tmp+"/etc/"+name, "/etc/"+name)
} }
} }
} }
} }
return container, maps.Clone(s.Env), nil return conf, nil
} }
func evalSymlinks(sys SandboxSys, v *string) error { func evalSymlinks(sys SandboxSys, v *string) error {

2
go.mod
View File

@ -1,3 +1,3 @@
module git.gensokyo.uk/security/fortify module git.gensokyo.uk/security/fortify
go 1.23 go 1.22

View File

@ -9,7 +9,7 @@ import (
"git.gensokyo.uk/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
) )
func Test_argsFd_String(t *testing.T) { func Test_argsFD_String(t *testing.T) {
wantString := strings.Join(wantArgs, " ") wantString := strings.Join(wantArgs, " ")
if got := argsWt.(fmt.Stringer).String(); got != wantString { if got := argsWt.(fmt.Stringer).String(); got != wantString {
t.Errorf("String(): got %v; want %v", t.Errorf("String(): got %v; want %v",

87
helper/bwrap.go Normal file
View File

@ -0,0 +1,87 @@
package helper
import (
"context"
"errors"
"io"
"os"
"slices"
"strconv"
"sync"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/helper/proc"
)
// BubblewrapName is the file name or path to bubblewrap.
var BubblewrapName = "bwrap"
type bubblewrap struct {
// final args fd of bwrap process
argsFd uintptr
// name of the command to run in bwrap
name string
lock sync.RWMutex
*helperCmd
}
func (b *bubblewrap) Start(ctx context.Context, stat bool) error {
b.lock.Lock()
defer b.lock.Unlock()
// 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.
if b.Cmd != nil && b.Cmd.Process != nil {
return errors.New("exec: already started")
}
args := b.finalise(ctx, stat)
b.Cmd.Args = slices.Grow(b.Cmd.Args, 4+len(args))
b.Cmd.Args = append(b.Cmd.Args, "--args", strconv.Itoa(int(b.argsFd)), "--", b.name)
b.Cmd.Args = append(b.Cmd.Args, args...)
return proc.Fulfill(ctx, b.Cmd, b.files, b.extraFiles)
}
// 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, 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 {
return b
}
}
// 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, name string,
wt io.WriterTo, argF func(argsFd, statFd int) []string,
extraFiles []*os.File,
syncFd *os.File,
) (Helper, error) {
b := new(bubblewrap)
b.name = name
b.helperCmd = newHelperCmd(b, BubblewrapName, wt, argF, extraFiles)
if v, err := NewCheckedArgs(conf.Args(syncFd, b.extraFiles, &b.files)); err != nil {
return nil, err
} else {
f := proc.NewWriterTo(v)
b.argsFd = proc.InitFile(f, b.extraFiles)
b.files = append(b.files, f)
}
return b, nil
}

72
helper/bwrap/arg.go Normal file
View File

@ -0,0 +1,72 @@
package bwrap
import (
"os"
"slices"
"git.gensokyo.uk/security/fortify/helper/proc"
)
type Builder interface {
Len() int
Append(args *[]string)
}
type FSBuilder interface {
Path() string
Builder
}
type FDBuilder interface {
proc.File
Builder
}
// Args returns a slice of bwrap args corresponding to c.
func (c *Config) Args(syncFd *os.File, extraFiles *proc.ExtraFilesPre, files *[]proc.File) (args []string) {
builders := []Builder{
c.boolArgs(),
c.intArgs(),
c.stringArgs(),
c.pairArgs(),
c.seccompArgs(),
newFile(SyncFd.String(), syncFd),
}
builders = slices.Grow(builders, len(c.Filesystem)+1)
for _, f := range c.Filesystem {
builders = append(builders, f)
}
builders = append(builders, c.Chmod)
argc := 0
fc := 0
for _, b := range builders {
l := b.Len()
if l < 1 {
continue
}
argc += l
if f, ok := b.(FDBuilder); ok {
fc++
proc.InitFile(f, extraFiles)
}
}
fc++ // allocate extra slot for stat fd
args = make([]string, 0, argc)
*files = slices.Grow(*files, fc)
for _, b := range builders {
if b.Len() < 1 {
continue
}
b.Append(&args)
if f, ok := b.(FDBuilder); ok {
*files = append(*files, f)
}
}
return
}

199
helper/bwrap/builder.go Normal file
View File

@ -0,0 +1,199 @@
package bwrap
import (
"os"
)
/*
Bind binds mount src on host to dest in sandbox.
Bind(src, dest) bind mount host path readonly on sandbox
(--ro-bind SRC DEST).
Bind(src, dest, true) equal to ROBind but ignores non-existent host path
(--ro-bind-try SRC DEST).
Bind(src, dest, false, true) bind mount host path on sandbox.
(--bind SRC DEST).
Bind(src, dest, true, true) equal to Bind but ignores non-existent host path
(--bind-try SRC DEST).
Bind(src, dest, false, true, true) bind mount host path on sandbox, allowing device access
(--dev-bind SRC DEST).
Bind(src, dest, true, true, true) equal to DevBind but ignores non-existent host path
(--dev-bind-try SRC DEST).
*/
func (c *Config) Bind(src, dest string, opts ...bool) *Config {
var (
try bool
write bool
dev bool
)
if len(opts) > 0 {
try = opts[0]
}
if len(opts) > 1 {
write = opts[1]
}
if len(opts) > 2 {
dev = opts[2]
}
if dev {
if try {
c.Filesystem = append(c.Filesystem, &pairF{DevBindTry.String(), src, dest})
} else {
c.Filesystem = append(c.Filesystem, &pairF{DevBind.String(), src, dest})
}
return c
} else if write {
if try {
c.Filesystem = append(c.Filesystem, &pairF{BindTry.String(), src, dest})
} else {
c.Filesystem = append(c.Filesystem, &pairF{Bind.String(), src, dest})
}
return c
} else {
if try {
c.Filesystem = append(c.Filesystem, &pairF{ROBindTry.String(), src, dest})
} else {
c.Filesystem = append(c.Filesystem, &pairF{ROBind.String(), src, dest})
}
return c
}
}
// WriteFile copy from FD to destination DEST
// (--file FD DEST)
func (c *Config) WriteFile(name string, data []byte) *Config {
c.Filesystem = append(c.Filesystem, &DataConfig{Dest: name, Data: data, Type: DataWrite})
return c
}
/*
CopyBind copy from FD to file which is readonly bind-mounted on DEST
(--ro-bind-data FD DEST)
CopyBind(dest, payload, true) copy from FD to file which is bind-mounted on DEST
(--bind-data FD DEST)
*/
func (c *Config) CopyBind(dest string, payload []byte, opts ...bool) *Config {
var p *[]byte
c.CopyBindRef(dest, &p, opts...)
*p = payload
return c
}
// CopyBindRef is the same as CopyBind but writes the address of DataConfig.Data.
func (c *Config) CopyBindRef(dest string, payloadRef **[]byte, opts ...bool) *Config {
t := DataROBind
if len(opts) > 0 && opts[0] {
t = DataBind
}
d := &DataConfig{Dest: dest, Type: t}
*payloadRef = &d.Data
c.Filesystem = append(c.Filesystem, d)
return c
}
// Dir create dir in sandbox
// (--dir DEST)
func (c *Config) Dir(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{Dir.String(), dest})
return c
}
// RemountRO remount path as readonly; does not recursively remount
// (--remount-ro DEST)
func (c *Config) RemountRO(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{RemountRO.String(), dest})
return c
}
// Procfs mount new procfs in sandbox
// (--proc DEST)
func (c *Config) Procfs(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{Procfs.String(), dest})
return c
}
// DevTmpfs mount new dev in sandbox
// (--dev DEST)
func (c *Config) DevTmpfs(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{DevTmpfs.String(), dest})
return c
}
// Mqueue mount new mqueue in sandbox
// (--mqueue DEST)
func (c *Config) Mqueue(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{Mqueue.String(), dest})
return c
}
// Tmpfs mount new tmpfs in sandbox
// (--tmpfs DEST)
func (c *Config) Tmpfs(dest string, size int, perm ...os.FileMode) *Config {
tmpfs := &PermConfig[*TmpfsConfig]{Inner: &TmpfsConfig{Dir: dest}}
if size >= 0 {
tmpfs.Inner.Size = size
}
if len(perm) == 1 {
tmpfs.Mode = &perm[0]
}
c.Filesystem = append(c.Filesystem, tmpfs)
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
}
// Symlink create symlink within sandbox
// (--symlink SRC DEST)
func (c *Config) Symlink(src, dest string, perm ...os.FileMode) *Config {
symlink := &PermConfig[SymlinkConfig]{Inner: SymlinkConfig{src, dest}}
if len(perm) == 1 {
symlink.Mode = &perm[0]
}
c.Filesystem = append(c.Filesystem, symlink)
return c
}
// SetUID sets custom uid in the sandbox, requires new user namespace (--uid UID).
func (c *Config) SetUID(uid int) *Config {
if uid >= 0 {
c.UID = &uid
}
return c
}
// SetGID sets custom gid in the sandbox, requires new user namespace (--gid GID).
func (c *Config) SetGID(gid int) *Config {
if gid >= 0 {
c.GID = &gid
}
return c
}

104
helper/bwrap/config.go Normal file
View File

@ -0,0 +1,104 @@
package bwrap
type Config struct {
// unshare every namespace we support by default if nil
// (--unshare-all)
Unshare *UnshareConfig `json:"unshare,omitempty"`
// retain the network namespace (can only combine with nil Unshare)
// (--share-net)
Net bool `json:"net"`
// disable further use of user namespaces inside sandbox and fail unless
// further use of user namespace inside sandbox is disabled if false
// (--disable-userns) (--assert-userns-disabled)
UserNS bool `json:"userns"`
// custom uid in the sandbox, requires new user namespace
// (--uid UID)
UID *int `json:"uid,omitempty"`
// custom gid in the sandbox, requires new user namespace
// (--gid GID)
GID *int `json:"gid,omitempty"`
// custom hostname in the sandbox, requires new uts namespace
// (--hostname NAME)
Hostname string `json:"hostname,omitempty"`
// change directory
// (--chdir DIR)
Chdir string `json:"chdir,omitempty"`
// unset all environment variables
// (--clearenv)
Clearenv bool `json:"clearenv"`
// set environment variable
// (--setenv VAR VALUE)
SetEnv map[string]string `json:"setenv,omitempty"`
// unset environment variables
// (--unsetenv VAR)
UnsetEnv []string `json:"unsetenv,omitempty"`
// take a lock on file while sandbox is running
// (--lock-file DEST)
LockFile []string `json:"lock_file,omitempty"`
// ordered filesystem args
Filesystem []FSBuilder `json:"filesystem,omitempty"`
// change permissions (must already exist)
// (--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"`
// kills with SIGKILL child process (COMMAND) when bwrap or bwrap's parent dies.
// (--die-with-parent)
DieWithParent bool `json:"die_with_parent"`
// do not install a reaper process with PID=1
// (--as-pid-1)
AsInit bool `json:"as_init"`
/* 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
--userns FD Use this user namespace (cannot combine with --unshare-user)
--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)
--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
--file-label LABEL File label for temporary sandbox content
--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
--info-fd FD Write information about the running container to FD
--json-status-fd FD Write container status to FD as multiple JSON documents
--cap-add CAP Add cap CAP when running as privileged user
--cap-drop CAP Drop cap CAP when running as privileged user
among which --args is used internally for passing arguments */
}
type UnshareConfig struct {
// (--unshare-user)
// create new user namespace
User bool `json:"user"`
// (--unshare-ipc)
// create new ipc namespace
IPC bool `json:"ipc"`
// (--unshare-pid)
// create new pid namespace
PID bool `json:"pid"`
// (--unshare-net)
// create new network namespace
Net bool `json:"net"`
// (--unshare-uts)
// create new uts namespace
UTS bool `json:"uts"`
// (--unshare-cgroup)
// create new cgroup namespace
CGroup bool `json:"cgroup"`
}

257
helper/bwrap/config_test.go Normal file
View File

@ -0,0 +1,257 @@
package bwrap_test
import (
"log"
"os"
"slices"
"testing"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/helper/proc"
"git.gensokyo.uk/security/fortify/helper/seccomp"
)
func TestConfig_Args(t *testing.T) {
seccomp.CPrintln = log.Println
t.Cleanup(func() { seccomp.CPrintln = nil })
testCases := []struct {
name string
conf *bwrap.Config
want []string
}{
{
"bind", (new(bwrap.Config)).
Bind("/etc", "/.fortify/etc").
Bind("/etc", "/.fortify/etc", true).
Bind("/run", "/.fortify/run", false, true).
Bind("/sys/devices", "/.fortify/sys/devices", true, true).
Bind("/dev/dri", "/.fortify/dev/dri", false, true, true).
Bind("/dev/dri", "/.fortify/dev/dri", true, true, true),
[]string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// Bind("/etc", "/.fortify/etc")
"--ro-bind", "/etc", "/.fortify/etc",
// Bind("/etc", "/.fortify/etc", true)
"--ro-bind-try", "/etc", "/.fortify/etc",
// Bind("/run", "/.fortify/run", false, true)
"--bind", "/run", "/.fortify/run",
// Bind("/sys/devices", "/.fortify/sys/devices", true, true)
"--bind-try", "/sys/devices", "/.fortify/sys/devices",
// Bind("/dev/dri", "/.fortify/dev/dri", false, true, true)
"--dev-bind", "/dev/dri", "/.fortify/dev/dri",
// Bind("/dev/dri", "/.fortify/dev/dri", true, true, true)
"--dev-bind-try", "/dev/dri", "/.fortify/dev/dri",
},
},
{
"dir remount-ro proc dev mqueue", (new(bwrap.Config)).
Dir("/.fortify").
RemountRO("/home").
Procfs("/proc").
DevTmpfs("/dev").
Mqueue("/dev/mqueue"),
[]string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// Dir("/.fortify")
"--dir", "/.fortify",
// RemountRO("/home")
"--remount-ro", "/home",
// Procfs("/proc")
"--proc", "/proc",
// DevTmpfs("/dev")
"--dev", "/dev",
// Mqueue("/dev/mqueue")
"--mqueue", "/dev/mqueue",
},
},
{
"tmpfs", (new(bwrap.Config)).
Tmpfs("/run/user", 8192).
Tmpfs("/run/dbus", 8192, 0755),
[]string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// Tmpfs("/run/user", 8192)
"--size", "8192", "--tmpfs", "/run/user",
// Tmpfs("/run/dbus", 8192, 0755)
"--perms", "755", "--size", "8192", "--tmpfs", "/run/dbus",
},
},
{
"symlink", (new(bwrap.Config)).
Symlink("/.fortify/sbin/init", "/sbin/init").
Symlink("/.fortify/sbin/init", "/sbin/init", 0755),
[]string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// Symlink("/.fortify/sbin/init", "/sbin/init")
"--symlink", "/.fortify/sbin/init", "/sbin/init",
// Symlink("/.fortify/sbin/init", "/sbin/init", 0755)
"--perms", "755", "--symlink", "/.fortify/sbin/init", "/sbin/init",
},
},
{
"overlayfs", (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"),
[]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",
},
},
{
"copy", (new(bwrap.Config)).
WriteFile("/.fortify/version", make([]byte, 8)).
CopyBind("/etc/group", make([]byte, 8)).
CopyBind("/etc/passwd", make([]byte, 8), true),
[]string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// Write("/.fortify/version", make([]byte, 8))
"--file", "3", "/.fortify/version",
// CopyBind("/etc/group", make([]byte, 8))
"--ro-bind-data", "4", "/etc/group",
// CopyBind("/etc/passwd", make([]byte, 8), true)
"--bind-data", "5", "/etc/passwd",
},
},
{
"unshare", &bwrap.Config{Unshare: &bwrap.UnshareConfig{
User: false,
IPC: false,
PID: false,
Net: false,
UTS: false,
CGroup: false,
}},
[]string{"--disable-userns", "--assert-userns-disabled"},
},
{
"uid gid sync", (new(bwrap.Config)).
SetUID(1971).
SetGID(100),
[]string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// SetUID(1971)
"--uid", "1971",
// SetGID(100)
"--gid", "100",
},
},
{
"hostname chdir setenv unsetenv lockfile chmod syscall", &bwrap.Config{
Hostname: "fortify",
Chdir: "/.fortify",
SetEnv: map[string]string{"FORTIFY_INIT": "/.fortify/sbin/init"},
UnsetEnv: []string{"HOME", "HOST"},
LockFile: []string{"/.fortify/lock"},
Syscall: new(bwrap.SyscallPolicy),
Chmod: map[string]os.FileMode{"/.fortify/sbin/init": 0755},
},
[]string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// Hostname: "fortify"
"--hostname", "fortify",
// Chdir: "/.fortify"
"--chdir", "/.fortify",
// UnsetEnv: []string{"HOME", "HOST"}
"--unsetenv", "HOME",
"--unsetenv", "HOST",
// LockFile: []string{"/.fortify/lock"},
"--lock-file", "/.fortify/lock",
// SetEnv: map[string]string{"FORTIFY_INIT": "/.fortify/sbin/init"}
"--setenv", "FORTIFY_INIT", "/.fortify/sbin/init",
// Syscall: new(bwrap.SyscallPolicy),
"--seccomp", "3",
// Chmod: map[string]os.FileMode{"/.fortify/sbin/init": 0755}
"--chmod", "755", "/.fortify/sbin/init",
},
},
{
"xdg-dbus-proxy constraint sample", (&bwrap.Config{Clearenv: true, DieWithParent: true}).
Symlink("usr/bin", "/bin").
Symlink("var/home", "/home").
Symlink("usr/lib", "/lib").
Symlink("usr/lib64", "/lib64").
Symlink("run/media", "/media").
Symlink("var/mnt", "/mnt").
Symlink("var/opt", "/opt").
Symlink("sysroot/ostree", "/ostree").
Symlink("var/roothome", "/root").
Symlink("usr/sbin", "/sbin").
Symlink("var/srv", "/srv").
Bind("/run", "/run", false, true).
Bind("/tmp", "/tmp", false, true).
Bind("/var", "/var", false, true).
Bind("/run/user/1971/.dbus-proxy/", "/run/user/1971/.dbus-proxy/", false, true).
Bind("/boot", "/boot").
Bind("/dev", "/dev").
Bind("/proc", "/proc").
Bind("/sys", "/sys").
Bind("/sysroot", "/sysroot").
Bind("/usr", "/usr").
Bind("/etc", "/etc"),
[]string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
"--clearenv", "--die-with-parent",
"--symlink", "usr/bin", "/bin",
"--symlink", "var/home", "/home",
"--symlink", "usr/lib", "/lib",
"--symlink", "usr/lib64", "/lib64",
"--symlink", "run/media", "/media",
"--symlink", "var/mnt", "/mnt",
"--symlink", "var/opt", "/opt",
"--symlink", "sysroot/ostree", "/ostree",
"--symlink", "var/roothome", "/root",
"--symlink", "usr/sbin", "/sbin",
"--symlink", "var/srv", "/srv",
"--bind", "/run", "/run",
"--bind", "/tmp", "/tmp",
"--bind", "/var", "/var",
"--bind", "/run/user/1971/.dbus-proxy/", "/run/user/1971/.dbus-proxy/",
"--ro-bind", "/boot", "/boot",
"--ro-bind", "/dev", "/dev",
"--ro-bind", "/proc", "/proc",
"--ro-bind", "/sys", "/sys",
"--ro-bind", "/sysroot", "/sysroot",
"--ro-bind", "/usr", "/usr",
"--ro-bind", "/etc", "/etc",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.conf.Args(nil, new(proc.ExtraFilesPre), new([]proc.File)); !slices.Equal(got, tc.want) {
t.Errorf("Args() = %#v, want %#v", got, tc.want)
}
})
}
// test persist validation
t.Run("invalid persist", func(t *testing.T) {
defer func() {
wantPanic := "persist called without required paths"
if r := recover(); r != wantPanic {
t.Errorf("Persist() panic = %v; wantPanic %v", r, wantPanic)
}
}()
(new(bwrap.Config)).Persist("/run", "", "")
})
}

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

@ -0,0 +1,85 @@
package bwrap
import (
"fmt"
"strconv"
"git.gensokyo.uk/security/fortify/helper/proc"
"git.gensokyo.uk/security/fortify/helper/seccomp"
)
type SyscallPolicy struct {
// disable fortify extensions
Compat bool `json:"compat"`
// deny development syscalls
DenyDevel bool `json:"deny_devel"`
// deny multiarch/emulation syscalls
Multiarch bool `json:"multiarch"`
// allow PER_LINUX32
Linux32 bool `json:"linux32"`
// allow AF_CAN
Can bool `json:"can"`
// allow AF_BLUETOOTH
Bluetooth bool `json:"bluetooth"`
}
func (c *Config) seccompArgs() FDBuilder {
// explicitly disable syscall filter
if c.Syscall == nil {
// nil File skips builder
return new(seccompBuilder)
}
var (
opts seccomp.SyscallOpts
optd []string
optCond = [...]struct {
v bool
o seccomp.SyscallOpts
d string
}{
{!c.Syscall.Compat, seccomp.FlagExt, "fortify"},
{!c.UserNS, seccomp.FlagDenyNS, "denyns"},
{c.NewSession, seccomp.FlagDenyTTY, "denytty"},
{c.Syscall.DenyDevel, seccomp.FlagDenyDevel, "denydevel"},
{c.Syscall.Multiarch, seccomp.FlagMultiarch, "multiarch"},
{c.Syscall.Linux32, seccomp.FlagLinux32, "linux32"},
{c.Syscall.Can, seccomp.FlagCan, "can"},
{c.Syscall.Bluetooth, seccomp.FlagBluetooth, "bluetooth"},
}
)
if seccomp.CPrintln != nil {
optd = make([]string, 1, len(optCond)+1)
optd[0] = "common"
}
for _, opt := range optCond {
if opt.v {
opts |= opt.o
if seccomp.CPrintln != nil {
optd = append(optd, opt.d)
}
}
}
if seccomp.CPrintln != nil {
seccomp.CPrintln(fmt.Sprintf("seccomp flags: %s", optd))
}
return &seccompBuilder{seccomp.NewFile(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, Seccomp.String(), strconv.Itoa(int(s.Fd())))
}

273
helper/bwrap/sequential.go Normal file
View File

@ -0,0 +1,273 @@
package bwrap
import (
"encoding/gob"
"fmt"
"io"
"os"
"strconv"
"git.gensokyo.uk/security/fortify/helper/proc"
)
func init() {
gob.Register(new(PermConfig[SymlinkConfig]))
gob.Register(new(PermConfig[*TmpfsConfig]))
gob.Register(new(OverlayConfig))
gob.Register(new(DataConfig))
}
type PositionalArg int
func (p PositionalArg) String() string { return positionalArgs[p] }
const (
Tmpfs PositionalArg = iota
Symlink
Bind
BindTry
DevBind
DevBindTry
ROBind
ROBindTry
Chmod
Dir
RemountRO
Procfs
DevTmpfs
Mqueue
Perms
Size
OverlaySrc
Overlay
TmpOverlay
ROOverlay
SyncFd
Seccomp
File
BindData
ROBindData
)
var positionalArgs = [...]string{
Tmpfs: "--tmpfs",
Symlink: "--symlink",
Bind: "--bind",
BindTry: "--bind-try",
DevBind: "--dev-bind",
DevBindTry: "--dev-bind-try",
ROBind: "--ro-bind",
ROBindTry: "--ro-bind-try",
Chmod: "--chmod",
Dir: "--dir",
RemountRO: "--remount-ro",
Procfs: "--proc",
DevTmpfs: "--dev",
Mqueue: "--mqueue",
Perms: "--perms",
Size: "--size",
OverlaySrc: "--overlay-src",
Overlay: "--overlay",
TmpOverlay: "--tmp-overlay",
ROOverlay: "--ro-overlay",
SyncFd: "--sync-fd",
Seccomp: "--seccomp",
File: "--file",
BindData: "--bind-data",
ROBindData: "--ro-bind-data",
}
type PermConfig[T FSBuilder] struct {
// set permissions of next argument
// (--perms OCTAL)
Mode *os.FileMode `json:"mode,omitempty"`
// path to get the new permission
// (--bind-data, --file, etc.)
Inner T `json:"path"`
}
func (p *PermConfig[T]) Path() string { return p.Inner.Path() }
func (p *PermConfig[T]) Len() int {
if p.Mode != nil {
return p.Inner.Len() + 2
} else {
return p.Inner.Len()
}
}
func (p *PermConfig[T]) Append(args *[]string) {
if p.Mode != nil {
*args = append(*args, Perms.String(), strconv.FormatInt(int64(*p.Mode), 8))
}
p.Inner.Append(args)
}
type TmpfsConfig struct {
// set size of tmpfs
// (--size BYTES)
Size int `json:"size,omitempty"`
// mount point of new tmpfs
// (--tmpfs DEST)
Dir string `json:"dir"`
}
func (t *TmpfsConfig) Path() string { return t.Dir }
func (t *TmpfsConfig) Len() int {
if t.Size > 0 {
return 4
} else {
return 2
}
}
func (t *TmpfsConfig) Append(args *[]string) {
if t.Size > 0 {
*args = append(*args, Size.String(), strconv.Itoa(t.Size))
}
*args = append(*args, Tmpfs.String(), 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, OverlaySrc.String(), src)
}
if o.Persist != nil {
if o.Persist[0] != "" && o.Persist[1] != "" {
// --overlay RWSRC WORKDIR
*args = append(*args, Overlay.String(), o.Persist[0], o.Persist[1])
} else {
// --ro-overlay
*args = append(*args, ROOverlay.String())
}
} else {
// --tmp-overlay
*args = append(*args, TmpOverlay.String())
}
// DEST
*args = append(*args, o.Dest)
}
type SymlinkConfig [2]string
func (s SymlinkConfig) Path() string { return s[1] }
func (s SymlinkConfig) Len() int { return 3 }
func (s SymlinkConfig) Append(args *[]string) { *args = append(*args, Symlink.String(), s[0], s[1]) }
type ChmodConfig map[string]os.FileMode
func (c ChmodConfig) Len() int { return len(c) }
func (c ChmodConfig) Append(args *[]string) {
for path, mode := range c {
*args = append(*args, Chmod.String(), strconv.FormatInt(int64(mode), 8), path)
}
}
const (
DataWrite = iota
DataBind
DataROBind
)
type DataConfig struct {
Dest string `json:"dest"`
Data []byte `json:"data,omitempty"`
Type int `json:"type"`
proc.File
}
func (d *DataConfig) Path() string { return d.Dest }
func (d *DataConfig) Len() int {
if d == nil || d.Data == nil {
return 0
}
return 3
}
func (d *DataConfig) Init(fd uintptr, v **os.File) uintptr {
if d.File != nil {
panic("file initialised twice")
}
d.File = proc.NewWriterTo(d)
return d.File.Init(fd, v)
}
func (d *DataConfig) WriteTo(w io.Writer) (int64, error) {
n, err := w.Write(d.Data)
return int64(n), err
}
func (d *DataConfig) Append(args *[]string) {
if d == nil || d.Data == nil {
return
}
var a PositionalArg
switch d.Type {
case DataWrite:
a = File
case DataBind:
a = BindData
case DataROBind:
a = ROBindData
default:
panic(fmt.Sprintf("invalid type %d", a))
}
*args = append(*args, a.String(), strconv.Itoa(int(d.Fd())), d.Dest)
}

249
helper/bwrap/static.go Normal file
View File

@ -0,0 +1,249 @@
package bwrap
import (
"slices"
"strconv"
)
/*
static boolean args
*/
type BoolArg int
func (b BoolArg) Unwrap() []string {
return boolArgs[b]
}
const (
UnshareAll BoolArg = iota
UnshareUser
UnshareIPC
UnsharePID
UnshareNet
UnshareUTS
UnshareCGroup
ShareNet
UserNS
Clearenv
NewSession
DieWithParent
AsInit
)
var boolArgs = [...][]string{
UnshareAll: {"--unshare-all", "--unshare-user"},
UnshareUser: {"--unshare-user"},
UnshareIPC: {"--unshare-ipc"},
UnsharePID: {"--unshare-pid"},
UnshareNet: {"--unshare-net"},
UnshareUTS: {"--unshare-uts"},
UnshareCGroup: {"--unshare-cgroup"},
ShareNet: {"--share-net"},
UserNS: {"--disable-userns", "--assert-userns-disabled"},
Clearenv: {"--clearenv"},
NewSession: {"--new-session"},
DieWithParent: {"--die-with-parent"},
AsInit: {"--as-pid-1"},
}
func (c *Config) boolArgs() Builder {
b := boolArg{
UserNS: !c.UserNS,
Clearenv: c.Clearenv,
NewSession: c.NewSession,
DieWithParent: c.DieWithParent,
AsInit: c.AsInit,
}
if c.Unshare == nil {
b[UnshareAll] = true
b[ShareNet] = c.Net
} else {
b[UnshareUser] = c.Unshare.User
b[UnshareIPC] = c.Unshare.IPC
b[UnsharePID] = c.Unshare.PID
b[UnshareNet] = c.Unshare.Net
b[UnshareUTS] = c.Unshare.UTS
b[UnshareCGroup] = c.Unshare.CGroup
}
return &b
}
type boolArg [len(boolArgs)]bool
func (b *boolArg) Len() (l int) {
for i, v := range b {
if v {
l += len(boolArgs[i])
}
}
return
}
func (b *boolArg) Append(args *[]string) {
for i, v := range b {
if v {
*args = append(*args, BoolArg(i).Unwrap()...)
}
}
}
/*
static integer args
*/
type IntArg int
func (i IntArg) Unwrap() string {
return intArgs[i]
}
const (
UID IntArg = iota
GID
)
var intArgs = [...]string{
UID: "--uid",
GID: "--gid",
}
func (c *Config) intArgs() Builder {
return &intArg{
UID: c.UID,
GID: c.GID,
}
}
type intArg [len(intArgs)]*int
func (n *intArg) Len() (l int) {
for _, v := range n {
if v != nil {
l += 2
}
}
return
}
func (n *intArg) Append(args *[]string) {
for i, v := range n {
if v != nil {
*args = append(*args, IntArg(i).Unwrap(), strconv.Itoa(*v))
}
}
}
/*
static string args
*/
type StringArg int
func (s StringArg) Unwrap() string {
return stringArgs[s]
}
const (
Hostname StringArg = iota
Chdir
UnsetEnv
LockFile
)
var stringArgs = [...]string{
Hostname: "--hostname",
Chdir: "--chdir",
UnsetEnv: "--unsetenv",
LockFile: "--lock-file",
}
func (c *Config) stringArgs() Builder {
n := stringArg{
UnsetEnv: c.UnsetEnv,
LockFile: c.LockFile,
}
if c.Hostname != "" {
n[Hostname] = []string{c.Hostname}
}
if c.Chdir != "" {
n[Chdir] = []string{c.Chdir}
}
return &n
}
type stringArg [len(stringArgs)][]string
func (s *stringArg) Len() (l int) {
for _, arg := range s {
l += len(arg) * 2
}
return
}
func (s *stringArg) Append(args *[]string) {
for i, arg := range s {
for _, v := range arg {
*args = append(*args, StringArg(i).Unwrap(), v)
}
}
}
/*
static pair args
*/
type PairArg int
func (p PairArg) Unwrap() string {
return pairArgs[p]
}
const (
SetEnv PairArg = iota
)
var pairArgs = [...]string{
SetEnv: "--setenv",
}
func (c *Config) pairArgs() Builder {
var n pairArg
n[SetEnv] = make([][2]string, len(c.SetEnv))
keys := make([]string, 0, len(c.SetEnv))
for k := range c.SetEnv {
keys = append(keys, k)
}
slices.Sort(keys)
for i, k := range keys {
n[SetEnv][i] = [2]string{k, c.SetEnv[k]}
}
return &n
}
type pairArg [len(pairArgs)][][2]string
func (p *pairArg) Len() (l int) {
for _, v := range p {
l += len(v) * 3
}
return
}
func (p *pairArg) Append(args *[]string) {
for i, arg := range p {
for _, v := range arg {
*args = append(*args, PairArg(i).Unwrap(), v[0], v[1])
}
}
}

52
helper/bwrap/trivial.go Normal file
View File

@ -0,0 +1,52 @@
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())))
}

103
helper/bwrap_test.go Normal file
View File

@ -0,0 +1,103 @@
package helper_test
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/helper/bwrap"
)
func TestBwrap(t *testing.T) {
sc := &bwrap.Config{
Net: true,
Hostname: "localhost",
Chdir: "/nonexistent",
Clearenv: true,
NewSession: true,
DieWithParent: true,
AsInit: true,
}
t.Run("nonexistent bwrap name", func(t *testing.T) {
bubblewrapName := helper.BubblewrapName
helper.BubblewrapName = "/nonexistent"
t.Cleanup(func() {
helper.BubblewrapName = bubblewrapName
})
h := helper.MustNewBwrap(
sc, "fortify",
argsWt, argF,
nil, nil,
)
if err := h.Start(context.Background(), false); !errors.Is(err, os.ErrNotExist) {
t.Errorf("Start: error = %v, wantErr %v",
err, os.ErrNotExist)
}
})
t.Run("valid new helper nil check", func(t *testing.T) {
if got := helper.MustNewBwrap(
sc, "fortify",
argsWt, argF,
nil, nil,
); got == nil {
t.Errorf("MustNewBwrap(%#v, %#v, %#v) got nil",
sc, argsWt, "fortify")
return
}
})
t.Run("invalid bwrap config new helper panic", func(t *testing.T) {
defer func() {
wantPanic := "argument contains null character"
if r := recover(); r != wantPanic {
t.Errorf("MustNewBwrap: panic = %q, want %q",
r, wantPanic)
}
}()
helper.MustNewBwrap(
&bwrap.Config{Hostname: "\x00"}, "fortify",
nil, argF,
nil, nil,
)
})
t.Run("start without pipes", func(t *testing.T) {
helper.InternalReplaceExecCommand(t)
h := helper.MustNewBwrap(
sc, "crash-test-dummy",
nil, argFChecked,
nil, nil,
)
stdout, stderr := new(strings.Builder), new(strings.Builder)
h.Stdout(stdout).Stderr(stderr)
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.Start(c, false); err != nil {
t.Errorf("Start: error = %v",
err)
return
}
if err := h.Wait(); err != nil {
t.Errorf("Wait() err = %v stderr = %s",
err, stderr)
}
})
t.Run("implementation compliance", func(t *testing.T) {
testHelper(t, func() helper.Helper { return helper.MustNewBwrap(sc, "crash-test-dummy", argsWt, argF, nil, nil) })
})
}

View File

@ -1,84 +0,0 @@
package helper
import (
"context"
"errors"
"io"
"os"
"os/exec"
"slices"
"sync"
"syscall"
"git.gensokyo.uk/security/fortify/helper/proc"
)
// NewDirect 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.
func NewDirect(
ctx context.Context,
name string,
wt io.WriterTo,
stat bool,
argF func(argsFd, statFd int) []string,
cmdF func(cmd *exec.Cmd),
extraFiles []*os.File,
) Helper {
d, args := newHelperCmd(ctx, name, wt, stat, argF, extraFiles)
d.Args = append(d.Args, args...)
if cmdF != nil {
cmdF(d.Cmd)
}
return d
}
func newHelperCmd(
ctx context.Context,
name string,
wt io.WriterTo,
stat bool,
argF func(argsFd, statFd int) []string,
extraFiles []*os.File,
) (cmd *helperCmd, args []string) {
cmd = new(helperCmd)
cmd.helperFiles, args = newHelperFiles(ctx, wt, stat, argF, extraFiles)
cmd.Cmd = exec.CommandContext(ctx, name)
cmd.Cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
cmd.WaitDelay = WaitDelay
return
}
// helperCmd provides a [exec.Cmd] wrapper around helper ipc.
type helperCmd struct {
mu sync.RWMutex
*helperFiles
*exec.Cmd
}
func (h *helperCmd) Start() error {
h.mu.Lock()
defer h.mu.Unlock()
// 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.
if h.Cmd != nil && h.Cmd.Process != nil {
return errors.New("helper: already started")
}
h.Env = slices.Grow(h.Env, 2)
if h.useArgsFd {
h.Env = append(h.Env, FortifyHelper+"=1")
} else {
h.Env = append(h.Env, FortifyHelper+"=0")
}
if h.useStatFd {
h.Env = append(h.Env, FortifyStatus+"=1")
// stat is populated on fulfill
h.Cancel = func() error { return h.stat.Close() }
} else {
h.Env = append(h.Env, FortifyStatus+"=0")
}
return proc.Fulfill(h.helperFiles.ctx, &h.ExtraFiles, h.Cmd.Start, h.files, h.extraFiles)
}

View File

@ -1,39 +0,0 @@
package helper_test
import (
"context"
"errors"
"io"
"os"
"os/exec"
"testing"
"git.gensokyo.uk/security/fortify/helper"
)
func TestCmd(t *testing.T) {
t.Run("start non-existent helper path", func(t *testing.T) {
h := helper.NewDirect(context.Background(), "/proc/nonexistent", argsWt, false, argF, nil, nil)
if err := h.Start(); !errors.Is(err, os.ErrNotExist) {
t.Errorf("Start: error = %v, wantErr %v",
err, os.ErrNotExist)
}
})
t.Run("valid new helper nil check", func(t *testing.T) {
if got := helper.NewDirect(context.TODO(), "fortify", argsWt, false, argF, nil, nil); got == nil {
t.Errorf("NewDirect(%q, %q) got nil",
argsWt, "fortify")
return
}
})
t.Run("implementation compliance", func(t *testing.T) {
testHelper(t, func(ctx context.Context, setOutput func(stdoutP, stderrP *io.Writer), stat bool) helper.Helper {
return helper.NewDirect(ctx, os.Args[0], argsWt, stat, argF, func(cmd *exec.Cmd) {
setOutput(&cmd.Stdout, &cmd.Stderr)
}, nil)
})
})
}

View File

@ -1,76 +0,0 @@
package helper
import (
"context"
"errors"
"io"
"os"
"os/exec"
"slices"
"sync"
"git.gensokyo.uk/security/fortify/helper/proc"
"git.gensokyo.uk/security/fortify/sandbox"
)
// New initialises a Helper instance with wt as the null-terminated argument writer.
func New(
ctx context.Context,
name string,
wt io.WriterTo,
stat bool,
argF func(argsFd, statFd int) []string,
cmdF func(container *sandbox.Container),
extraFiles []*os.File,
) Helper {
var args []string
h := new(helperContainer)
h.helperFiles, args = newHelperFiles(ctx, wt, stat, argF, extraFiles)
h.Container = sandbox.New(ctx, name, args...)
h.WaitDelay = WaitDelay
if cmdF != nil {
cmdF(h.Container)
}
return h
}
// helperContainer provides a [sandbox.Container] wrapper around helper ipc.
type helperContainer struct {
started bool
mu sync.Mutex
*helperFiles
*sandbox.Container
}
func (h *helperContainer) Start() error {
h.mu.Lock()
defer h.mu.Unlock()
if h.started {
return errors.New("helper: already started")
}
h.started = true
h.Env = slices.Grow(h.Env, 2)
if h.useArgsFd {
h.Env = append(h.Env, FortifyHelper+"=1")
} else {
h.Env = append(h.Env, FortifyHelper+"=0")
}
if h.useStatFd {
h.Env = append(h.Env, FortifyStatus+"=1")
// stat is populated on fulfill
h.Cancel = func(*exec.Cmd) error { return h.stat.Close() }
} else {
h.Env = append(h.Env, FortifyStatus+"=0")
}
return proc.Fulfill(h.helperFiles.ctx, &h.ExtraFiles, func() error {
if err := h.Container.Start(); err != nil {
return err
}
return h.Container.Serve()
}, h.files, h.extraFiles)
}

View File

@ -1,57 +0,0 @@
package helper_test
import (
"context"
"io"
"os"
"os/exec"
"testing"
"git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/sandbox"
)
func TestContainer(t *testing.T) {
t.Run("start empty container", func(t *testing.T) {
h := helper.New(context.Background(), "/nonexistent", argsWt, false, argF, nil, nil)
wantErr := "sandbox: starting an empty container"
if err := h.Start(); err == nil || err.Error() != wantErr {
t.Errorf("Start: error = %v, wantErr %q",
err, wantErr)
}
})
t.Run("valid new helper nil check", func(t *testing.T) {
if got := helper.New(context.TODO(), "fortify", argsWt, false, argF, nil, nil); got == nil {
t.Errorf("New(%q, %q) got nil",
argsWt, "fortify")
return
}
})
t.Run("implementation compliance", func(t *testing.T) {
testHelper(t, func(ctx context.Context, setOutput func(stdoutP, stderrP *io.Writer), stat bool) helper.Helper {
return helper.New(ctx, os.Args[0], argsWt, stat, argF, func(container *sandbox.Container) {
setOutput(&container.Stdout, &container.Stderr)
container.CommandContext = func(ctx context.Context) (cmd *exec.Cmd) {
return exec.CommandContext(ctx, os.Args[0], "-test.v",
"-test.run=TestHelperInit", "--", "init")
}
container.Bind("/", "/", 0)
container.Proc("/proc")
container.Dev("/dev")
}, nil)
})
})
}
func TestHelperInit(t *testing.T) {
if len(os.Args) != 5 || os.Args[4] != "init" {
return
}
sandbox.SetOutput(fmsg.Output{})
sandbox.Init(fmsg.Prepare, func(bool) { internal.InstallFmsg(false) })
}

40
helper/direct.go Normal file
View File

@ -0,0 +1,40 @@
package helper
import (
"context"
"errors"
"io"
"sync"
"git.gensokyo.uk/security/fortify/helper/proc"
)
// direct wraps *exec.Cmd and manages status and args fd.
// Args is always 3 and status if set is always 4.
type direct struct {
lock sync.RWMutex
*helperCmd
}
func (h *direct) Start(ctx context.Context, stat bool) error {
h.lock.Lock()
defer h.lock.Unlock()
// 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.
if h.Cmd != nil && h.Cmd.Process != nil {
return errors.New("exec: already started")
}
args := h.finalise(ctx, stat)
h.Cmd.Args = append(h.Cmd.Args, args...)
return proc.Fulfill(ctx, h.Cmd, h.files, h.extraFiles)
}
// 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.
func New(wt io.WriterTo, name string, argF func(argsFd, statFd int) []string) Helper {
d := new(direct)
d.helperCmd = newHelperCmd(d, name, wt, argF, nil)
return d
}

33
helper/direct_test.go Normal file
View File

@ -0,0 +1,33 @@
package helper_test
import (
"context"
"errors"
"os"
"testing"
"git.gensokyo.uk/security/fortify/helper"
)
func TestDirect(t *testing.T) {
t.Run("start non-existent helper path", func(t *testing.T) {
h := helper.New(argsWt, "/nonexistent", argF)
if err := h.Start(context.Background(), false); !errors.Is(err, os.ErrNotExist) {
t.Errorf("Start: error = %v, wantErr %v",
err, os.ErrNotExist)
}
})
t.Run("valid new helper nil check", func(t *testing.T) {
if got := helper.New(argsWt, "fortify", argF); got == nil {
t.Errorf("New(%q, %q) got nil",
argsWt, "fortify")
return
}
})
t.Run("implementation compliance", func(t *testing.T) {
testHelper(t, func() helper.Helper { return helper.New(argsWt, "crash-test-dummy", argF) })
})
}

View File

@ -1,4 +1,4 @@
// Package helper runs external helpers with optional sandboxing. // Package helper runs external helpers with optional sandboxing and manages their status/args pipes.
package helper package helper
import ( import (
@ -6,12 +6,17 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"os/exec"
"slices"
"syscall"
"time" "time"
"git.gensokyo.uk/security/fortify/helper/proc" "git.gensokyo.uk/security/fortify/helper/proc"
) )
var WaitDelay = 2 * time.Second var (
WaitDelay = 2 * time.Second
)
const ( const (
// FortifyHelper is set to 1 when args fd is enabled and 0 otherwise. // FortifyHelper is set to 1 when args fd is enabled and 0 otherwise.
@ -21,56 +26,62 @@ const (
) )
type Helper interface { type Helper interface {
// Stdin sets the standard input of Helper.
Stdin(r io.Reader) Helper
// Stdout sets the standard output of Helper.
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.
Start() error // A status pipe is passed to the helper if stat is true.
// Wait blocks until Helper exits. Start(ctx context.Context, stat bool) error
// Wait blocks until Helper exits and releases all its resources.
Wait() error Wait() error
fmt.Stringer fmt.Stringer
} }
func newHelperFiles( func newHelperCmd(
ctx context.Context, h Helper, name string,
wt io.WriterTo, wt io.WriterTo, argF func(argsFd, statFd int) []string,
stat bool,
argF func(argsFd, statFd int) []string,
extraFiles []*os.File, extraFiles []*os.File,
) (hl *helperFiles, args []string) { ) (cmd *helperCmd) {
hl = new(helperFiles) cmd = new(helperCmd)
hl.ctx = ctx
hl.useArgsFd = wt != nil
hl.useStatFd = stat
hl.extraFiles = new(proc.ExtraFilesPre) cmd.r = h
cmd.name = name
cmd.extraFiles = new(proc.ExtraFilesPre)
for _, f := range extraFiles { for _, f := range extraFiles {
_, v := hl.extraFiles.Append() _, v := cmd.extraFiles.Append()
*v = f *v = f
} }
argsFd := -1 argsFd := -1
if hl.useArgsFd { if wt != nil {
f := proc.NewWriterTo(wt) f := proc.NewWriterTo(wt)
argsFd = int(proc.InitFile(f, hl.extraFiles)) argsFd = int(proc.InitFile(f, cmd.extraFiles))
hl.files = append(hl.files, f) cmd.files = append(cmd.files, f)
cmd.hasArgsFd = true
} }
cmd.argF = func(statFd int) []string { return argF(argsFd, statFd) }
statFd := -1
if hl.useStatFd {
f := proc.NewStat(&hl.stat)
statFd = int(proc.InitFile(f, hl.extraFiles))
hl.files = append(hl.files, f)
}
args = argF(argsFd, statFd)
return return
} }
// helperFiles provides a generic wrapper around helper ipc. // helperCmd wraps Cmd and implements methods shared across all Helper implementations.
type helperFiles struct { 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 // whether argsFd is present
useArgsFd bool hasArgsFd bool
// whether statFd is present
useStatFd bool
// closes statFd // closes statFd
stat io.Closer stat io.Closer
@ -79,5 +90,45 @@ type helperFiles struct {
// passed through to [proc.Fulfill] and [proc.InitFile] // passed through to [proc.Fulfill] and [proc.InitFile]
extraFiles *proc.ExtraFilesPre extraFiles *proc.ExtraFilesPre
ctx context.Context 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

@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@ -36,8 +35,7 @@ func argF(argsFd, statFd int) []string {
} }
func argFChecked(argsFd, statFd int) (args []string) { func argFChecked(argsFd, statFd int) (args []string) {
args = make([]string, 0, 6) args = make([]string, 0, 4)
args = append(args, "-test.run=TestHelperStub", "--")
if argsFd > -1 { if argsFd > -1 {
args = append(args, "--args", strconv.Itoa(argsFd)) args = append(args, "--args", strconv.Itoa(argsFd))
} }
@ -48,15 +46,14 @@ func argFChecked(argsFd, statFd int) (args []string) {
} }
// this function tests an implementation of the helper.Helper interface // this function tests an implementation of the helper.Helper interface
func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput func(stdoutP, stderrP *io.Writer), stat bool) helper.Helper) { func testHelper(t *testing.T, createHelper func() helper.Helper) {
oldWaitDelay := helper.WaitDelay helper.InternalReplaceExecCommand(t)
helper.WaitDelay = 16 * time.Second
t.Cleanup(func() { helper.WaitDelay = oldWaitDelay })
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) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) h := createHelper()
stdout, stderr := new(strings.Builder), new(strings.Builder) stdout, stderr := new(strings.Builder), new(strings.Builder)
h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, stderr }, true) h.Stdout(stdout).Stderr(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() { defer func() {
@ -68,8 +65,10 @@ func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput f
panic(fmt.Sprintf("unreachable: %v", h.Wait())) panic(fmt.Sprintf("unreachable: %v", h.Wait()))
}) })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Log("starting helper stub") t.Log("starting helper stub")
if err := h.Start(); err != nil { if err := h.Start(ctx, true); err != nil {
t.Errorf("Start: error = %v", err) t.Errorf("Start: error = %v", err)
cancel() cancel()
return return
@ -78,8 +77,8 @@ func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput f
cancel() cancel()
t.Run("start already started helper", func(t *testing.T) { t.Run("start already started helper", func(t *testing.T) {
wantErr := "helper: already started" wantErr := "exec: already started"
if err := h.Start(); err != nil && err.Error() != wantErr { if err := h.Start(ctx, true); err != nil && err.Error() != wantErr {
t.Errorf("Start: error = %v, wantErr %v", t.Errorf("Start: error = %v, wantErr %v",
err, wantErr) err, wantErr)
return return
@ -101,19 +100,21 @@ func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput f
} }
}) })
if got := stderr.String(); got != wantPayload { if got := stdout.String(); !strings.HasPrefix(got, wantPayload) {
t.Errorf("Start: stderr = %v, want %v", t.Errorf("Start: stdout = %v, want %v",
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()
stdout, stderr := new(strings.Builder), new(strings.Builder)
h.Stdout(stdout).Stderr(stderr)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
stdout, stderr := new(strings.Builder), new(strings.Builder)
h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, stderr }, false)
if err := h.Start(); err != nil { if err := h.Start(ctx, false); err != nil {
t.Errorf("Start() error = %v", t.Errorf("Start() error = %v",
err) err)
return return
@ -124,8 +125,8 @@ func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput f
err, stdout, stderr) err, stdout, stderr)
} }
if got := stderr.String(); got != wantPayload { if got := stdout.String(); !strings.HasPrefix(got, wantPayload) {
t.Errorf("Start() stderr = %v, want %v", t.Errorf("Start() stdout = %v, want %v",
got, wantPayload) got, wantPayload)
} }
}) })

View File

@ -1,4 +1,4 @@
package sandbox package proc
import ( import (
"encoding/gob" "encoding/gob"
@ -12,7 +12,7 @@ var (
ErrInvalid = errors.New("bad file descriptor") ErrInvalid = errors.New("bad file descriptor")
) )
// Setup appends the read end of a pipe for setup params transmission and returns its fd. // Setup appends the read end of a pipe for payload transmission and returns its fd.
func Setup(extraFiles *[]*os.File) (int, *gob.Encoder, error) { func Setup(extraFiles *[]*os.File) (int, *gob.Encoder, error) {
if r, w, err := os.Pipe(); err != nil { if r, w, err := os.Pipe(); err != nil {
return -1, nil, err return -1, nil, err
@ -23,8 +23,9 @@ func Setup(extraFiles *[]*os.File) (int, *gob.Encoder, error) {
} }
} }
// Receive retrieves setup fd from the environment and receives params. // Receive retrieves payload pipe fd from the environment,
func Receive(key string, e any, v **os.File) (func() error, error) { // receives its payload and returns the Close method of the pipe.
func Receive(key string, e any) (func() error, error) {
var setup *os.File var setup *os.File
if s, ok := os.LookupEnv(key); !ok { if s, ok := os.LookupEnv(key); !ok {
@ -37,11 +38,8 @@ func Receive(key string, e any, v **os.File) (func() error, error) {
if setup == nil { if setup == nil {
return nil, ErrInvalid return nil, ErrInvalid
} }
if v != nil {
*v = setup
}
} }
} }
return setup.Close, gob.NewDecoder(setup).Decode(e) return func() error { return setup.Close() }, gob.NewDecoder(setup).Decode(e)
} }

View File

@ -60,10 +60,7 @@ func (f *ExtraFilesPre) copy(e []*os.File) []*os.File {
} }
// Fulfill calls the [File.Fulfill] method on all files, starts cmd and blocks until all fulfillment completes. // Fulfill calls the [File.Fulfill] method on all files, starts cmd and blocks until all fulfillment completes.
func Fulfill(ctx context.Context, func Fulfill(ctx context.Context, cmd *exec.Cmd, files []File, extraFiles *ExtraFilesPre) (err error) {
v *[]*os.File, start func() error,
files []File, extraFiles *ExtraFilesPre,
) (err error) {
var ecs int var ecs int
for _, o := range files { for _, o := range files {
ecs += o.ErrCount() ecs += o.ErrCount()
@ -80,8 +77,8 @@ func Fulfill(ctx context.Context,
} }
} }
*v = extraFiles.Files() cmd.ExtraFiles = extraFiles.Files()
if err = start(); err != nil { if err = cmd.Start(); err != nil {
return return
} }

View File

@ -11,9 +11,6 @@ import (
// New returns an inactive Encoder instance. // New returns an inactive Encoder instance.
func New(opts SyscallOpts) *Encoder { return &Encoder{newExporter(opts)} } func New(opts SyscallOpts) *Encoder { return &Encoder{newExporter(opts)} }
// Load loads a filter into the kernel.
func Load(opts SyscallOpts) error { return buildFilter(-1, opts) }
/* /*
An Encoder writes a BPF program to an output stream. An Encoder writes a BPF program to an output stream.

View File

@ -28,7 +28,7 @@ func (e *exporter) prepare() error {
ec := make(chan error, 1) ec := make(chan error, 1)
go func(fd uintptr) { go func(fd uintptr) {
ec <- buildFilter(int(fd), e.opts) ec <- exportFilter(fd, e.opts)
close(ec) close(ec)
_ = e.closeWrite() _ = e.closeWrite()
runtime.KeepAlive(e.w) runtime.KeepAlive(e.w)

View File

@ -4,11 +4,12 @@ import (
"crypto/sha512" "crypto/sha512"
"errors" "errors"
"io" "io"
"log"
"slices" "slices"
"syscall" "syscall"
"testing" "testing"
"git.gensokyo.uk/security/fortify/sandbox/seccomp" "git.gensokyo.uk/security/fortify/helper/seccomp"
) )
func TestExport(t *testing.T) { func TestExport(t *testing.T) {
@ -78,9 +79,8 @@ func TestExport(t *testing.T) {
buf := make([]byte, 8) buf := make([]byte, 8)
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
oldF := seccomp.GetOutput() seccomp.CPrintln = log.Println
seccomp.SetOutput(t.Log) t.Cleanup(func() { seccomp.CPrintln = nil })
t.Cleanup(func() { seccomp.SetOutput(oldF) })
e := seccomp.New(tc.opts) e := seccomp.New(tc.opts)
digest := sha512.New() digest := sha512.New()
@ -93,7 +93,7 @@ func TestExport(t *testing.T) {
t.Errorf("Close: error = %v", err) t.Errorf("Close: error = %v", err)
return return
} }
if got := digest.Sum(nil); !slices.Equal(got, tc.want) { if got := digest.Sum(nil); slices.Compare(got, tc.want) != 0 {
t.Fatalf("Export() hash = %x, want %x", t.Fatalf("Export() hash = %x, want %x",
got, tc.want) got, tc.want)
return return
@ -111,14 +111,11 @@ func TestExport(t *testing.T) {
t.Run("close partial read", func(t *testing.T) { t.Run("close partial read", func(t *testing.T) {
e := seccomp.New(0) e := seccomp.New(0)
if _, err := e.Read(nil); err != nil { if _, err := e.Read(make([]byte, 0)); err != nil {
t.Errorf("Read: error = %v", err) t.Errorf("Read: error = %v", err)
return return
} }
// the underlying implementation uses buffered io, so the outcome of this is nondeterministic; if err := e.Close(); err == nil || err.Error() != "seccomp_export_bpf failed: operation canceled" {
// that is not harmful however, so both outcomes are checked for here
if err := e.Close(); err != nil &&
(!errors.Is(err, syscall.ECANCELED) || !errors.Is(err, syscall.EBADF)) {
t.Errorf("Close: error = %v", err) t.Errorf("Close: error = %v", err)
return return
} }

View File

@ -2,7 +2,7 @@
#define _GNU_SOURCE // CLONE_NEWUSER #define _GNU_SOURCE // CLONE_NEWUSER
#endif #endif
#include "seccomp-build.h" #include "seccomp-export.h"
#include <stdlib.h> #include <stdlib.h>
#include <stdio.h> #include <stdio.h>
#include <assert.h> #include <assert.h>
@ -33,21 +33,22 @@ struct f_syscall_act {
assert(ruleset[i].m_errno == EPERM || ruleset[i].m_errno == ENOSYS); \ assert(ruleset[i].m_errno == EPERM || ruleset[i].m_errno == ENOSYS); \
\ \
if (ruleset[i].arg) \ if (ruleset[i].arg) \
*ret_p = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(ruleset[i].m_errno), ruleset[i].syscall, 1, *ruleset[i].arg); \ ret = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(ruleset[i].m_errno), ruleset[i].syscall, 1, *ruleset[i].arg); \
else \ else \
*ret_p = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(ruleset[i].m_errno), ruleset[i].syscall, 0); \ ret = seccomp_rule_add(ctx, SCMP_ACT_ERRNO(ruleset[i].m_errno), ruleset[i].syscall, 0); \
\ \
if (*ret_p == -EFAULT) { \ if (ret == -EFAULT) { \
res = 4; \ res = 4; \
goto out; \ goto out; \
} else if (*ret_p < 0) { \ } else if (ret < 0) { \
res = 5; \ res = 5; \
errno = -ret; \
goto out; \ goto out; \
} \ } \
} \ } \
} while (0) } while (0)
int32_t f_build_filter(int *ret_p, 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;
int allowed_personality = PER_LINUX; int allowed_personality = PER_LINUX;
@ -228,6 +229,8 @@ int32_t f_build_filter(int *ret_p, int fd, uint32_t arch, uint32_t multiarch, f_
} else } else
errno = 0; errno = 0;
int ret;
// We only really need to handle arches on multiarch systems. // We only really need to handle arches on multiarch systems.
// If only one arch is supported the default is fine // If only one arch is supported the default is fine
if (arch != 0) { if (arch != 0) {
@ -236,16 +239,18 @@ int32_t f_build_filter(int *ret_p, int fd, uint32_t arch, uint32_t multiarch, f_
// allow the target arch, but we can't really disallow the // allow the target arch, but we can't really disallow the
// native arch at this point, because then bubblewrap // native arch at this point, because then bubblewrap
// couldn't continue running. // couldn't continue running.
*ret_p = seccomp_arch_add(ctx, arch); ret = seccomp_arch_add(ctx, arch);
if (*ret_p < 0 && *ret_p != -EEXIST) { if (ret < 0 && ret != -EEXIST) {
res = 2; res = 2;
errno = -ret;
goto out; goto out;
} }
if (allow_multiarch && multiarch != 0) { if (allow_multiarch && multiarch != 0) {
*ret_p = seccomp_arch_add(ctx, multiarch); ret = seccomp_arch_add(ctx, multiarch);
if (*ret_p < 0 && *ret_p != -EEXIST) { if (ret < 0 && ret != -EEXIST) {
res = 3; res = 3;
errno = -ret;
goto out; goto out;
} }
} }
@ -280,19 +285,12 @@ int32_t f_build_filter(int *ret_p, int fd, uint32_t arch, uint32_t multiarch, f_
// Blocklist the rest // 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)); seccomp_rule_add_exact(ctx, SCMP_ACT_ERRNO(EAFNOSUPPORT), SCMP_SYS(socket), 1, SCMP_A0(SCMP_CMP_GE, last_allowed_family + 1));
if (fd < 0) { ret = seccomp_export_bpf(ctx, fd);
*ret_p = seccomp_load(ctx); if (ret != 0) {
if (*ret_p != 0) {
res = 7;
goto out;
}
} else {
*ret_p = seccomp_export_bpf(ctx, fd);
if (*ret_p != 0) {
res = 6; res = 6;
errno = -ret;
goto out; goto out;
} }
}
out: out:
if (ctx) if (ctx)

View File

@ -20,4 +20,4 @@ typedef enum {
} f_syscall_opts; } f_syscall_opts;
extern void F_println(char *v); extern void F_println(char *v);
int32_t f_build_filter(int *ret_p, 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

@ -3,56 +3,25 @@ package seccomp
/* /*
#cgo linux pkg-config: --static libseccomp #cgo linux pkg-config: --static libseccomp
#include "seccomp-build.h" #include "seccomp-export.h"
*/ */
import "C" import "C"
import ( import (
"errors" "errors"
"fmt" "fmt"
"runtime" "runtime"
"syscall"
) )
// LibraryError represents a libseccomp error. var CPrintln func(v ...any)
type LibraryError struct {
Prefix string
Seccomp syscall.Errno
Errno error
}
func (e *LibraryError) Error() string { var resErr = [...]error{
if e.Seccomp == 0 { 0: nil,
if e.Errno == nil { 1: errors.New("seccomp_init failed"),
panic("invalid libseccomp error") 2: errors.New("seccomp_arch_add failed"),
} 3: errors.New("seccomp_arch_add failed (multiarch)"),
return fmt.Sprintf("%s: %s", e.Prefix, e.Errno) 4: errors.New("internal libseccomp failure"),
} 5: errors.New("seccomp_rule_add failed"),
if e.Errno == nil { 6: errors.New("seccomp_export_bpf failed"),
return fmt.Sprintf("%s: %s", e.Prefix, e.Seccomp)
}
return fmt.Sprintf("%s: %s (%s)", e.Prefix, e.Seccomp, e.Errno)
}
func (e *LibraryError) Is(err error) bool {
if e == nil {
return err == nil
}
if ef, ok := err.(*LibraryError); ok {
return *e == *ef
}
return (e.Seccomp != 0 && errors.Is(err, e.Seccomp)) ||
(e.Errno != nil && errors.Is(err, e.Errno))
}
var resPrefix = [...]string{
0: "",
1: "seccomp_init failed",
2: "seccomp_arch_add failed",
3: "seccomp_arch_add failed (multiarch)",
4: "internal libseccomp failure",
5: "seccomp_rule_add failed",
6: "seccomp_export_bpf failed",
7: "seccomp_load failed",
} }
type SyscallOpts = C.f_syscall_opts type SyscallOpts = C.f_syscall_opts
@ -77,7 +46,7 @@ const (
FlagBluetooth SyscallOpts = C.F_BLUETOOTH FlagBluetooth SyscallOpts = C.F_BLUETOOTH
) )
func buildFilter(fd int, opts SyscallOpts) error { func exportFilter(fd uintptr, opts SyscallOpts) error {
var ( var (
arch C.uint32_t = 0 arch C.uint32_t = 0
multiarch C.uint32_t = 0 multiarch C.uint32_t = 0
@ -97,18 +66,23 @@ func buildFilter(fd int, opts SyscallOpts) error {
// this removes repeated transitions between C and Go execution // this removes repeated transitions between C and Go execution
// when producing log output via F_println and CPrintln is nil // when producing log output via F_println and CPrintln is nil
if fp := printlnP.Load(); fp != nil { if CPrintln != nil {
opts |= flagVerbose opts |= flagVerbose
} }
var ret C.int res, err := C.f_export_bpf(C.int(fd), arch, multiarch, opts)
res, err := C.f_build_filter(&ret, C.int(fd), arch, multiarch, opts) if re := resErr[res]; re != nil {
if prefix := resPrefix[res]; prefix != "" { if err == nil {
return &LibraryError{ return re
prefix,
-syscall.Errno(ret),
err,
} }
return fmt.Errorf("%s: %v", re.Error(), err)
} }
return err return err
} }
//export F_println
func F_println(v *C.char) {
if CPrintln != nil {
CPrintln(C.GoString(v))
}
}

View File

@ -1,17 +1,25 @@
package helper package helper
import ( import (
"context"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"os" "os"
"os/exec"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"testing"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/helper/proc"
"git.gensokyo.uk/security/fortify/internal"
) )
// InternalHelperStub is an internal function but exported because it is cross-package; // InternalChildStub 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 InternalHelperStub() { func InternalChildStub() {
// this test mocks the helper process // this test mocks the helper process
var ap, sp string var ap, sp string
if v, ok := os.LookupEnv(FortifyHelper); !ok { if v, ok := os.LookupEnv(FortifyHelper); !ok {
@ -25,9 +33,30 @@ func InternalHelperStub() {
sp = v sp = v
} }
genericStub(flagRestoreFiles(3, ap, sp)) switch os.Args[3] {
case "bwrap":
bwrapStub()
default:
genericStub(flagRestoreFiles(4, ap, sp))
}
os.Exit(0) internal.Exit(0)
}
// InternalReplaceExecCommand is an internal function but exported because it is cross-package;
// it is part of the implementation of the helper stub.
func InternalReplaceExecCommand(t *testing.T) {
t.Cleanup(func() { commandContext = exec.CommandContext })
// replace execCommand to have the resulting *exec.Cmd launch TestHelperChildStub
commandContext = func(ctx context.Context, name string, arg ...string) *exec.Cmd {
// pass through nonexistent path
if name == "/nonexistent" && len(arg) == 0 {
return exec.CommandContext(ctx, name)
}
return exec.CommandContext(ctx, os.Args[0], append([]string{"-test.run=TestHelperChildStub", "--", name}, arg...)...)
}
} }
func newFile(fd int, name, p string) *os.File { func newFile(fd int, name, p string) *os.File {
@ -63,7 +92,7 @@ func flagRestoreFiles(offset int, ap, sp string) (argsFile, statFile *os.File) {
func genericStub(argsFile, statFile *os.File) { func genericStub(argsFile, statFile *os.File) {
if argsFile != nil { if argsFile != nil {
// this output is checked by parent // this output is checked by parent
if _, err := io.Copy(os.Stderr, argsFile); err != nil { if _, err := io.Copy(os.Stdout, argsFile); err != nil {
panic("cannot read args: " + err.Error()) panic("cannot read args: " + err.Error())
} }
} }
@ -104,3 +133,42 @@ func genericStub(argsFile, statFile *os.File) {
<-done <-done
} }
} }
func bwrapStub() {
// the bwrap launcher does not launch with a typical sync fd
argsFile, _ := flagRestoreFiles(4, "1", "0")
// test args pipe behaviour
func() {
got, want := new(strings.Builder), new(strings.Builder)
if _, err := io.Copy(got, argsFile); err != nil {
panic("cannot read bwrap args: " + err.Error())
}
// hardcoded bwrap configuration used by test
sc := &bwrap.Config{
Net: true,
Hostname: "localhost",
Chdir: "/nonexistent",
Clearenv: true,
NewSession: true,
DieWithParent: true,
AsInit: true,
}
if _, err := MustNewCheckedArgs(sc.Args(nil, new(proc.ExtraFilesPre), new([]proc.File))).
WriteTo(want); err != nil {
panic("cannot read want: " + err.Error())
}
if len(flag.CommandLine.Args()) > 0 && flag.CommandLine.Args()[0] == "crash-test-dummy" && got.String() != want.String() {
panic("bad bwrap args\ngot: " + got.String() + "\nwant: " + want.String())
}
}()
if err := syscall.Exec(
os.Args[0],
append([]string{os.Args[0], "-test.run=TestHelperChildStub", "--"}, flag.CommandLine.Args()...),
os.Environ()); err != nil {
panic("cannot start general stub: " + err.Error())
}
}

View File

@ -6,4 +6,6 @@ import (
"git.gensokyo.uk/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
) )
func TestHelperStub(t *testing.T) { helper.InternalHelperStub() } func TestHelperChildStub(t *testing.T) {
helper.InternalChildStub()
}

View File

@ -1,7 +1,6 @@
package app package app
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"sync" "sync"
@ -11,10 +10,9 @@ import (
"git.gensokyo.uk/security/fortify/internal/sys" "git.gensokyo.uk/security/fortify/internal/sys"
) )
func New(ctx context.Context, os sys.State) (fst.App, error) { func New(os sys.State) (fst.App, error) {
a := new(app) a := new(app)
a.sys = os a.sys = os
a.ctx = ctx
id := new(fst.ID) id := new(fst.ID)
err := fst.NewAppID(id) err := fst.NewAppID(id)
@ -23,8 +21,8 @@ func New(ctx context.Context, os sys.State) (fst.App, error) {
return a, err return a, err
} }
func MustNew(ctx context.Context, os sys.State) fst.App { func MustNew(os sys.State) fst.App {
a, err := New(ctx, os) a, err := New(os)
if err != nil { if err != nil {
log.Fatalf("cannot create app: %v", err) log.Fatalf("cannot create app: %v", err)
} }
@ -34,7 +32,6 @@ func MustNew(ctx context.Context, os sys.State) fst.App {
type app struct { type app struct {
id *stringPair[fst.ID] id *stringPair[fst.ID]
sys sys.State sys sys.State
ctx context.Context
*outcome *outcome
mu sync.RWMutex mu sync.RWMutex
@ -74,7 +71,7 @@ func (a *app) Seal(config *fst.Config) (fst.SealedApp, error) {
seal := new(outcome) seal := new(outcome)
seal.id = a.id seal.id = a.id
err := seal.finalise(a.ctx, a.sys, config) err := seal.finalise(a.sys, config)
if err == nil { if err == nil {
a.outcome = seal a.outcome = seal
} }

View File

@ -4,7 +4,7 @@ import (
"git.gensokyo.uk/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/sandbox" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/system" "git.gensokyo.uk/security/fortify/system"
) )
@ -13,19 +13,19 @@ var testCasesNixos = []sealTestCase{
"nixos chromium direct wayland", new(stubNixOS), "nixos chromium direct wayland", new(stubNixOS),
&fst.Config{ &fst.Config{
ID: "org.chromium.Chromium", ID: "org.chromium.Chromium",
Path: "/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start", Command: []string{"/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"},
Confinement: fst.ConfinementConfig{ Confinement: fst.ConfinementConfig{
AppID: 1, Groups: []string{}, Username: "u0_a1", AppID: 1, Groups: []string{}, Username: "u0_a1",
Outer: "/var/lib/persist/module/fortify/0/1", Outer: "/var/lib/persist/module/fortify/0/1",
Sandbox: &fst.SandboxConfig{ Sandbox: &fst.SandboxConfig{
Userns: true, Net: true, MapRealUID: true, DirectWayland: true, Env: nil, AutoEtc: true, UserNS: true, Net: true, MapRealUID: true, DirectWayland: true, Env: nil, AutoEtc: true,
Filesystem: []*fst.FilesystemConfig{ Filesystem: []*fst.FilesystemConfig{
{Src: "/bin", Must: true}, {Src: "/usr/bin", Must: true}, {Src: "/bin", Must: true}, {Src: "/usr/bin", Must: true},
{Src: "/nix/store", Must: true}, {Src: "/run/current-system", Must: true}, {Src: "/nix/store", Must: true}, {Src: "/run/current-system", Must: true},
{Src: "/sys/block"}, {Src: "/sys/bus"}, {Src: "/sys/class"}, {Src: "/sys/dev"}, {Src: "/sys/devices"}, {Src: "/sys/block"}, {Src: "/sys/bus"}, {Src: "/sys/class"}, {Src: "/sys/dev"}, {Src: "/sys/devices"},
{Src: "/run/opengl-driver", Must: true}, {Src: "/dev/dri", Device: true}, {Src: "/run/opengl-driver", Must: true}, {Src: "/dev/dri", Device: true},
}, },
Cover: []string{"/var/run/nscd"}, Override: []string{"/var/run/nscd"},
}, },
SystemBus: &dbus.Config{ SystemBus: &dbus.Config{
Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"}, Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"},
@ -45,7 +45,7 @@ var testCasesNixos = []sealTestCase{
Call: map[string]string{}, Broadcast: map[string]string{}, Call: map[string]string{}, Broadcast: map[string]string{},
Filter: true, Filter: true,
}, },
Enablements: system.EWayland | system.EDBus | system.EPulse, Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(),
}, },
}, },
fst.ID{ fst.ID{
@ -88,132 +88,136 @@ var testCasesNixos = []sealTestCase{
}). }).
UpdatePerm("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", acl.Read, acl.Write). UpdatePerm("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", acl.Read, acl.Write).
UpdatePerm("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", acl.Read, acl.Write), UpdatePerm("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", acl.Read, acl.Write),
&sandbox.Params{ (&bwrap.Config{
Uid: 1971, Net: true,
Gid: 100, UserNS: true,
Flags: sandbox.FAllowNet | sandbox.FAllowUserns, Chdir: "/var/lib/persist/module/fortify/0/1",
Dir: "/var/lib/persist/module/fortify/0/1", Clearenv: true,
Path: "/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start", SetEnv: map[string]string{
Args: []string{"/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"}, "DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1971/bus",
Env: []string{ "DBUS_SYSTEM_BUS_ADDRESS": "unix:path=/run/dbus/system_bus_socket",
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1971/bus", "HOME": "/var/lib/persist/module/fortify/0/1",
"DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket", "PULSE_COOKIE": fst.Tmp + "/pulse-cookie",
"HOME=/var/lib/persist/module/fortify/0/1", "PULSE_SERVER": "unix:/run/user/1971/pulse/native",
"PULSE_COOKIE=" + fst.Tmp + "/pulse-cookie", "SHELL": "/run/current-system/sw/bin/zsh",
"PULSE_SERVER=unix:/run/user/1971/pulse/native", "TERM": "xterm-256color",
"TERM=xterm-256color", "USER": "u0_a1",
"USER=u0_a1", "WAYLAND_DISPLAY": "wayland-0",
"WAYLAND_DISPLAY=wayland-0", "XDG_RUNTIME_DIR": "/run/user/1971",
"XDG_RUNTIME_DIR=/run/user/1971", "XDG_SESSION_CLASS": "user",
"XDG_SESSION_CLASS=user", "XDG_SESSION_TYPE": "tty",
"XDG_SESSION_TYPE=tty",
},
Ops: new(sandbox.Ops).
Proc("/proc").
Tmpfs(fst.Tmp, 4096, 0755).
Dev("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", 0).
Bind("/usr/bin", "/usr/bin", 0).
Bind("/nix/store", "/nix/store", 0).
Bind("/run/current-system", "/run/current-system", 0).
Bind("/sys/block", "/sys/block", sandbox.BindOptional).
Bind("/sys/bus", "/sys/bus", sandbox.BindOptional).
Bind("/sys/class", "/sys/class", sandbox.BindOptional).
Bind("/sys/dev", "/sys/dev", sandbox.BindOptional).
Bind("/sys/devices", "/sys/devices", sandbox.BindOptional).
Bind("/run/opengl-driver", "/run/opengl-driver", 0).
Bind("/dev/dri", "/dev/dri", sandbox.BindDevice|sandbox.BindWritable|sandbox.BindOptional).
Bind("/etc", fst.Tmp+"/etc", 0).
Link(fst.Tmp+"/etc/alsa", "/etc/alsa").
Link(fst.Tmp+"/etc/bashrc", "/etc/bashrc").
Link(fst.Tmp+"/etc/binfmt.d", "/etc/binfmt.d").
Link(fst.Tmp+"/etc/dbus-1", "/etc/dbus-1").
Link(fst.Tmp+"/etc/default", "/etc/default").
Link(fst.Tmp+"/etc/ethertypes", "/etc/ethertypes").
Link(fst.Tmp+"/etc/fonts", "/etc/fonts").
Link(fst.Tmp+"/etc/fstab", "/etc/fstab").
Link(fst.Tmp+"/etc/fuse.conf", "/etc/fuse.conf").
Link(fst.Tmp+"/etc/host.conf", "/etc/host.conf").
Link(fst.Tmp+"/etc/hostid", "/etc/hostid").
Link(fst.Tmp+"/etc/hostname", "/etc/hostname").
Link(fst.Tmp+"/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Link(fst.Tmp+"/etc/hosts", "/etc/hosts").
Link(fst.Tmp+"/etc/inputrc", "/etc/inputrc").
Link(fst.Tmp+"/etc/ipsec.d", "/etc/ipsec.d").
Link(fst.Tmp+"/etc/issue", "/etc/issue").
Link(fst.Tmp+"/etc/kbd", "/etc/kbd").
Link(fst.Tmp+"/etc/libblockdev", "/etc/libblockdev").
Link(fst.Tmp+"/etc/locale.conf", "/etc/locale.conf").
Link(fst.Tmp+"/etc/localtime", "/etc/localtime").
Link(fst.Tmp+"/etc/login.defs", "/etc/login.defs").
Link(fst.Tmp+"/etc/lsb-release", "/etc/lsb-release").
Link(fst.Tmp+"/etc/lvm", "/etc/lvm").
Link(fst.Tmp+"/etc/machine-id", "/etc/machine-id").
Link(fst.Tmp+"/etc/man_db.conf", "/etc/man_db.conf").
Link(fst.Tmp+"/etc/modprobe.d", "/etc/modprobe.d").
Link(fst.Tmp+"/etc/modules-load.d", "/etc/modules-load.d").
Link("/proc/mounts", "/etc/mtab").
Link(fst.Tmp+"/etc/nanorc", "/etc/nanorc").
Link(fst.Tmp+"/etc/netgroup", "/etc/netgroup").
Link(fst.Tmp+"/etc/NetworkManager", "/etc/NetworkManager").
Link(fst.Tmp+"/etc/nix", "/etc/nix").
Link(fst.Tmp+"/etc/nixos", "/etc/nixos").
Link(fst.Tmp+"/etc/NIXOS", "/etc/NIXOS").
Link(fst.Tmp+"/etc/nscd.conf", "/etc/nscd.conf").
Link(fst.Tmp+"/etc/nsswitch.conf", "/etc/nsswitch.conf").
Link(fst.Tmp+"/etc/opensnitchd", "/etc/opensnitchd").
Link(fst.Tmp+"/etc/os-release", "/etc/os-release").
Link(fst.Tmp+"/etc/pam", "/etc/pam").
Link(fst.Tmp+"/etc/pam.d", "/etc/pam.d").
Link(fst.Tmp+"/etc/pipewire", "/etc/pipewire").
Link(fst.Tmp+"/etc/pki", "/etc/pki").
Link(fst.Tmp+"/etc/polkit-1", "/etc/polkit-1").
Link(fst.Tmp+"/etc/profile", "/etc/profile").
Link(fst.Tmp+"/etc/protocols", "/etc/protocols").
Link(fst.Tmp+"/etc/qemu", "/etc/qemu").
Link(fst.Tmp+"/etc/resolv.conf", "/etc/resolv.conf").
Link(fst.Tmp+"/etc/resolvconf.conf", "/etc/resolvconf.conf").
Link(fst.Tmp+"/etc/rpc", "/etc/rpc").
Link(fst.Tmp+"/etc/samba", "/etc/samba").
Link(fst.Tmp+"/etc/sddm.conf", "/etc/sddm.conf").
Link(fst.Tmp+"/etc/secureboot", "/etc/secureboot").
Link(fst.Tmp+"/etc/services", "/etc/services").
Link(fst.Tmp+"/etc/set-environment", "/etc/set-environment").
Link(fst.Tmp+"/etc/shadow", "/etc/shadow").
Link(fst.Tmp+"/etc/shells", "/etc/shells").
Link(fst.Tmp+"/etc/ssh", "/etc/ssh").
Link(fst.Tmp+"/etc/ssl", "/etc/ssl").
Link(fst.Tmp+"/etc/static", "/etc/static").
Link(fst.Tmp+"/etc/subgid", "/etc/subgid").
Link(fst.Tmp+"/etc/subuid", "/etc/subuid").
Link(fst.Tmp+"/etc/sudoers", "/etc/sudoers").
Link(fst.Tmp+"/etc/sysctl.d", "/etc/sysctl.d").
Link(fst.Tmp+"/etc/systemd", "/etc/systemd").
Link(fst.Tmp+"/etc/terminfo", "/etc/terminfo").
Link(fst.Tmp+"/etc/tmpfiles.d", "/etc/tmpfiles.d").
Link(fst.Tmp+"/etc/udev", "/etc/udev").
Link(fst.Tmp+"/etc/udisks2", "/etc/udisks2").
Link(fst.Tmp+"/etc/UPower", "/etc/UPower").
Link(fst.Tmp+"/etc/vconsole.conf", "/etc/vconsole.conf").
Link(fst.Tmp+"/etc/X11", "/etc/X11").
Link(fst.Tmp+"/etc/zfs", "/etc/zfs").
Link(fst.Tmp+"/etc/zinputrc", "/etc/zinputrc").
Link(fst.Tmp+"/etc/zoneinfo", "/etc/zoneinfo").
Link(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Link(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Link(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Tmpfs("/run/user", 4096, 0755).
Tmpfs("/run/user/1971", 8388608, 0755).
Bind("/tmp/fortify.1971/tmpdir/1", "/tmp", sandbox.BindWritable).
Bind("/var/lib/persist/module/fortify/0/1", "/var/lib/persist/module/fortify/0/1", sandbox.BindWritable).
Place("/etc/passwd", []byte("u0_a1:x:1971:100:Fortify:/var/lib/persist/module/fortify/0/1:/run/current-system/sw/bin/zsh\n")).
Place("/etc/group", []byte("fortify:x:100:\n")).
Bind("/run/user/1971/wayland-0", "/run/user/1971/wayland-0", 0).
Bind("/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1/pulse", "/run/user/1971/pulse/native", 0).
Place(fst.Tmp+"/pulse-cookie", nil).
Bind("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", "/run/user/1971/bus", 0).
Bind("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", "/run/dbus/system_bus_socket", 0).
Tmpfs("/var/run/nscd", 8192, 0755),
}, },
Chmod: make(bwrap.ChmodConfig),
NewSession: true,
DieWithParent: true,
AsInit: true,
}).SetUID(1971).SetGID(1971).
Procfs("/proc").
Tmpfs(fst.Tmp, 4096).
DevTmpfs("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin").
Bind("/usr/bin", "/usr/bin").
Bind("/nix/store", "/nix/store").
Bind("/run/current-system", "/run/current-system").
Bind("/sys/block", "/sys/block", true).
Bind("/sys/bus", "/sys/bus", true).
Bind("/sys/class", "/sys/class", true).
Bind("/sys/dev", "/sys/dev", true).
Bind("/sys/devices", "/sys/devices", true).
Bind("/run/opengl-driver", "/run/opengl-driver").
Bind("/dev/dri", "/dev/dri", true, true, true).
Bind("/etc", fst.Tmp+"/etc").
Symlink(fst.Tmp+"/etc/alsa", "/etc/alsa").
Symlink(fst.Tmp+"/etc/bashrc", "/etc/bashrc").
Symlink(fst.Tmp+"/etc/binfmt.d", "/etc/binfmt.d").
Symlink(fst.Tmp+"/etc/dbus-1", "/etc/dbus-1").
Symlink(fst.Tmp+"/etc/default", "/etc/default").
Symlink(fst.Tmp+"/etc/ethertypes", "/etc/ethertypes").
Symlink(fst.Tmp+"/etc/fonts", "/etc/fonts").
Symlink(fst.Tmp+"/etc/fstab", "/etc/fstab").
Symlink(fst.Tmp+"/etc/fuse.conf", "/etc/fuse.conf").
Symlink(fst.Tmp+"/etc/host.conf", "/etc/host.conf").
Symlink(fst.Tmp+"/etc/hostid", "/etc/hostid").
Symlink(fst.Tmp+"/etc/hostname", "/etc/hostname").
Symlink(fst.Tmp+"/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Symlink(fst.Tmp+"/etc/hosts", "/etc/hosts").
Symlink(fst.Tmp+"/etc/inputrc", "/etc/inputrc").
Symlink(fst.Tmp+"/etc/ipsec.d", "/etc/ipsec.d").
Symlink(fst.Tmp+"/etc/issue", "/etc/issue").
Symlink(fst.Tmp+"/etc/kbd", "/etc/kbd").
Symlink(fst.Tmp+"/etc/libblockdev", "/etc/libblockdev").
Symlink(fst.Tmp+"/etc/locale.conf", "/etc/locale.conf").
Symlink(fst.Tmp+"/etc/localtime", "/etc/localtime").
Symlink(fst.Tmp+"/etc/login.defs", "/etc/login.defs").
Symlink(fst.Tmp+"/etc/lsb-release", "/etc/lsb-release").
Symlink(fst.Tmp+"/etc/lvm", "/etc/lvm").
Symlink(fst.Tmp+"/etc/machine-id", "/etc/machine-id").
Symlink(fst.Tmp+"/etc/man_db.conf", "/etc/man_db.conf").
Symlink(fst.Tmp+"/etc/modprobe.d", "/etc/modprobe.d").
Symlink(fst.Tmp+"/etc/modules-load.d", "/etc/modules-load.d").
Symlink("/proc/mounts", "/etc/mtab").
Symlink(fst.Tmp+"/etc/nanorc", "/etc/nanorc").
Symlink(fst.Tmp+"/etc/netgroup", "/etc/netgroup").
Symlink(fst.Tmp+"/etc/NetworkManager", "/etc/NetworkManager").
Symlink(fst.Tmp+"/etc/nix", "/etc/nix").
Symlink(fst.Tmp+"/etc/nixos", "/etc/nixos").
Symlink(fst.Tmp+"/etc/NIXOS", "/etc/NIXOS").
Symlink(fst.Tmp+"/etc/nscd.conf", "/etc/nscd.conf").
Symlink(fst.Tmp+"/etc/nsswitch.conf", "/etc/nsswitch.conf").
Symlink(fst.Tmp+"/etc/opensnitchd", "/etc/opensnitchd").
Symlink(fst.Tmp+"/etc/os-release", "/etc/os-release").
Symlink(fst.Tmp+"/etc/pam", "/etc/pam").
Symlink(fst.Tmp+"/etc/pam.d", "/etc/pam.d").
Symlink(fst.Tmp+"/etc/pipewire", "/etc/pipewire").
Symlink(fst.Tmp+"/etc/pki", "/etc/pki").
Symlink(fst.Tmp+"/etc/polkit-1", "/etc/polkit-1").
Symlink(fst.Tmp+"/etc/profile", "/etc/profile").
Symlink(fst.Tmp+"/etc/protocols", "/etc/protocols").
Symlink(fst.Tmp+"/etc/qemu", "/etc/qemu").
Symlink(fst.Tmp+"/etc/resolv.conf", "/etc/resolv.conf").
Symlink(fst.Tmp+"/etc/resolvconf.conf", "/etc/resolvconf.conf").
Symlink(fst.Tmp+"/etc/rpc", "/etc/rpc").
Symlink(fst.Tmp+"/etc/samba", "/etc/samba").
Symlink(fst.Tmp+"/etc/sddm.conf", "/etc/sddm.conf").
Symlink(fst.Tmp+"/etc/secureboot", "/etc/secureboot").
Symlink(fst.Tmp+"/etc/services", "/etc/services").
Symlink(fst.Tmp+"/etc/set-environment", "/etc/set-environment").
Symlink(fst.Tmp+"/etc/shadow", "/etc/shadow").
Symlink(fst.Tmp+"/etc/shells", "/etc/shells").
Symlink(fst.Tmp+"/etc/ssh", "/etc/ssh").
Symlink(fst.Tmp+"/etc/ssl", "/etc/ssl").
Symlink(fst.Tmp+"/etc/static", "/etc/static").
Symlink(fst.Tmp+"/etc/subgid", "/etc/subgid").
Symlink(fst.Tmp+"/etc/subuid", "/etc/subuid").
Symlink(fst.Tmp+"/etc/sudoers", "/etc/sudoers").
Symlink(fst.Tmp+"/etc/sysctl.d", "/etc/sysctl.d").
Symlink(fst.Tmp+"/etc/systemd", "/etc/systemd").
Symlink(fst.Tmp+"/etc/terminfo", "/etc/terminfo").
Symlink(fst.Tmp+"/etc/tmpfiles.d", "/etc/tmpfiles.d").
Symlink(fst.Tmp+"/etc/udev", "/etc/udev").
Symlink(fst.Tmp+"/etc/udisks2", "/etc/udisks2").
Symlink(fst.Tmp+"/etc/UPower", "/etc/UPower").
Symlink(fst.Tmp+"/etc/vconsole.conf", "/etc/vconsole.conf").
Symlink(fst.Tmp+"/etc/X11", "/etc/X11").
Symlink(fst.Tmp+"/etc/zfs", "/etc/zfs").
Symlink(fst.Tmp+"/etc/zinputrc", "/etc/zinputrc").
Symlink(fst.Tmp+"/etc/zoneinfo", "/etc/zoneinfo").
Symlink(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Symlink(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Symlink(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/1971", 8388608).
Bind("/tmp/fortify.1971/tmpdir/1", "/tmp", false, true).
Bind("/var/lib/persist/module/fortify/0/1", "/var/lib/persist/module/fortify/0/1", false, true).
CopyBind("/etc/passwd", []byte("u0_a1:x:1971:1971:Fortify:/var/lib/persist/module/fortify/0/1:/run/current-system/sw/bin/zsh\n")).
CopyBind("/etc/group", []byte("fortify:x:1971:\n")).
Bind("/run/user/1971/wayland-0", "/run/user/1971/wayland-0").
Bind("/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1/pulse", "/run/user/1971/pulse/native").
CopyBind(fst.Tmp+"/pulse-cookie", nil).
Bind("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", "/run/user/1971/bus").
Bind("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", "/run/dbus/system_bus_socket").
Tmpfs("/var/run/nscd", 8192).
Bind("/run/wrappers/bin/fortify", "/.fortify/sbin/fortify").
Symlink("fortify", "/.fortify/sbin/init"),
}, },
} }

View File

@ -6,7 +6,7 @@ import (
"git.gensokyo.uk/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/sandbox" "git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/system" "git.gensokyo.uk/security/fortify/system"
) )
@ -14,6 +14,7 @@ var testCasesPd = []sealTestCase{
{ {
"nixos permissive defaults no enablements", new(stubNixOS), "nixos permissive defaults no enablements", new(stubNixOS),
&fst.Config{ &fst.Config{
Command: make([]string, 0),
Confinement: fst.ConfinementConfig{ Confinement: fst.ConfinementConfig{
AppID: 0, AppID: 0,
Username: "chronos", Username: "chronos",
@ -34,131 +35,136 @@ var testCasesPd = []sealTestCase{
Ephemeral(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", acl.Execute). Ephemeral(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute). Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/0", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/0", acl.Read, acl.Write, acl.Execute), Ensure("/tmp/fortify.1971/tmpdir/0", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/0", acl.Read, acl.Write, acl.Execute),
&sandbox.Params{ (&bwrap.Config{
Flags: sandbox.FAllowNet | sandbox.FAllowUserns | sandbox.FAllowTTY, Net: true,
Dir: "/home/chronos", UserNS: true,
Path: "/run/current-system/sw/bin/zsh", Clearenv: true,
Args: []string{"/run/current-system/sw/bin/zsh"}, Syscall: new(bwrap.SyscallPolicy),
Env: []string{ Chdir: "/home/chronos",
"HOME=/home/chronos", SetEnv: map[string]string{
"TERM=xterm-256color", "HOME": "/home/chronos",
"USER=chronos", "SHELL": "/run/current-system/sw/bin/zsh",
"XDG_RUNTIME_DIR=/run/user/65534", "TERM": "xterm-256color",
"XDG_SESSION_CLASS=user", "USER": "chronos",
"XDG_SESSION_TYPE=tty", "XDG_RUNTIME_DIR": "/run/user/65534",
}, "XDG_SESSION_CLASS": "user",
Ops: new(sandbox.Ops). "XDG_SESSION_TYPE": "tty"},
Proc("/proc"). Chmod: make(bwrap.ChmodConfig),
Tmpfs(fst.Tmp, 4096, 0755). DieWithParent: true,
Dev("/dev").Mqueue("/dev/mqueue"). AsInit: true,
Bind("/bin", "/bin", sandbox.BindWritable). }).SetUID(65534).SetGID(65534).
Bind("/boot", "/boot", sandbox.BindWritable). Procfs("/proc").
Bind("/home", "/home", sandbox.BindWritable). Tmpfs(fst.Tmp, 4096).
Bind("/lib", "/lib", sandbox.BindWritable). DevTmpfs("/dev").Mqueue("/dev/mqueue").
Bind("/lib64", "/lib64", sandbox.BindWritable). Bind("/bin", "/bin", false, true).
Bind("/nix", "/nix", sandbox.BindWritable). Bind("/boot", "/boot", false, true).
Bind("/root", "/root", sandbox.BindWritable). Bind("/home", "/home", false, true).
Bind("/run", "/run", sandbox.BindWritable). Bind("/lib", "/lib", false, true).
Bind("/srv", "/srv", sandbox.BindWritable). Bind("/lib64", "/lib64", false, true).
Bind("/sys", "/sys", sandbox.BindWritable). Bind("/nix", "/nix", false, true).
Bind("/usr", "/usr", sandbox.BindWritable). Bind("/root", "/root", false, true).
Bind("/var", "/var", sandbox.BindWritable). Bind("/run", "/run", false, true).
Bind("/dev/kvm", "/dev/kvm", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional). Bind("/srv", "/srv", false, true).
Tmpfs("/run/user/1971", 8192, 0755). Bind("/sys", "/sys", false, true).
Tmpfs("/run/dbus", 8192, 0755). Bind("/usr", "/usr", false, true).
Bind("/etc", fst.Tmp+"/etc", 0). Bind("/var", "/var", false, true).
Link(fst.Tmp+"/etc/alsa", "/etc/alsa"). Bind("/dev/kvm", "/dev/kvm", true, true, true).
Link(fst.Tmp+"/etc/bashrc", "/etc/bashrc"). Tmpfs("/run/user/1971", 8192).
Link(fst.Tmp+"/etc/binfmt.d", "/etc/binfmt.d"). Tmpfs("/run/dbus", 8192).
Link(fst.Tmp+"/etc/dbus-1", "/etc/dbus-1"). Bind("/etc", fst.Tmp+"/etc").
Link(fst.Tmp+"/etc/default", "/etc/default"). Symlink(fst.Tmp+"/etc/alsa", "/etc/alsa").
Link(fst.Tmp+"/etc/ethertypes", "/etc/ethertypes"). Symlink(fst.Tmp+"/etc/bashrc", "/etc/bashrc").
Link(fst.Tmp+"/etc/fonts", "/etc/fonts"). Symlink(fst.Tmp+"/etc/binfmt.d", "/etc/binfmt.d").
Link(fst.Tmp+"/etc/fstab", "/etc/fstab"). Symlink(fst.Tmp+"/etc/dbus-1", "/etc/dbus-1").
Link(fst.Tmp+"/etc/fuse.conf", "/etc/fuse.conf"). Symlink(fst.Tmp+"/etc/default", "/etc/default").
Link(fst.Tmp+"/etc/host.conf", "/etc/host.conf"). Symlink(fst.Tmp+"/etc/ethertypes", "/etc/ethertypes").
Link(fst.Tmp+"/etc/hostid", "/etc/hostid"). Symlink(fst.Tmp+"/etc/fonts", "/etc/fonts").
Link(fst.Tmp+"/etc/hostname", "/etc/hostname"). Symlink(fst.Tmp+"/etc/fstab", "/etc/fstab").
Link(fst.Tmp+"/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM"). Symlink(fst.Tmp+"/etc/fuse.conf", "/etc/fuse.conf").
Link(fst.Tmp+"/etc/hosts", "/etc/hosts"). Symlink(fst.Tmp+"/etc/host.conf", "/etc/host.conf").
Link(fst.Tmp+"/etc/inputrc", "/etc/inputrc"). Symlink(fst.Tmp+"/etc/hostid", "/etc/hostid").
Link(fst.Tmp+"/etc/ipsec.d", "/etc/ipsec.d"). Symlink(fst.Tmp+"/etc/hostname", "/etc/hostname").
Link(fst.Tmp+"/etc/issue", "/etc/issue"). Symlink(fst.Tmp+"/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Link(fst.Tmp+"/etc/kbd", "/etc/kbd"). Symlink(fst.Tmp+"/etc/hosts", "/etc/hosts").
Link(fst.Tmp+"/etc/libblockdev", "/etc/libblockdev"). Symlink(fst.Tmp+"/etc/inputrc", "/etc/inputrc").
Link(fst.Tmp+"/etc/locale.conf", "/etc/locale.conf"). Symlink(fst.Tmp+"/etc/ipsec.d", "/etc/ipsec.d").
Link(fst.Tmp+"/etc/localtime", "/etc/localtime"). Symlink(fst.Tmp+"/etc/issue", "/etc/issue").
Link(fst.Tmp+"/etc/login.defs", "/etc/login.defs"). Symlink(fst.Tmp+"/etc/kbd", "/etc/kbd").
Link(fst.Tmp+"/etc/lsb-release", "/etc/lsb-release"). Symlink(fst.Tmp+"/etc/libblockdev", "/etc/libblockdev").
Link(fst.Tmp+"/etc/lvm", "/etc/lvm"). Symlink(fst.Tmp+"/etc/locale.conf", "/etc/locale.conf").
Link(fst.Tmp+"/etc/machine-id", "/etc/machine-id"). Symlink(fst.Tmp+"/etc/localtime", "/etc/localtime").
Link(fst.Tmp+"/etc/man_db.conf", "/etc/man_db.conf"). Symlink(fst.Tmp+"/etc/login.defs", "/etc/login.defs").
Link(fst.Tmp+"/etc/modprobe.d", "/etc/modprobe.d"). Symlink(fst.Tmp+"/etc/lsb-release", "/etc/lsb-release").
Link(fst.Tmp+"/etc/modules-load.d", "/etc/modules-load.d"). Symlink(fst.Tmp+"/etc/lvm", "/etc/lvm").
Link("/proc/mounts", "/etc/mtab"). Symlink(fst.Tmp+"/etc/machine-id", "/etc/machine-id").
Link(fst.Tmp+"/etc/nanorc", "/etc/nanorc"). Symlink(fst.Tmp+"/etc/man_db.conf", "/etc/man_db.conf").
Link(fst.Tmp+"/etc/netgroup", "/etc/netgroup"). Symlink(fst.Tmp+"/etc/modprobe.d", "/etc/modprobe.d").
Link(fst.Tmp+"/etc/NetworkManager", "/etc/NetworkManager"). Symlink(fst.Tmp+"/etc/modules-load.d", "/etc/modules-load.d").
Link(fst.Tmp+"/etc/nix", "/etc/nix"). Symlink("/proc/mounts", "/etc/mtab").
Link(fst.Tmp+"/etc/nixos", "/etc/nixos"). Symlink(fst.Tmp+"/etc/nanorc", "/etc/nanorc").
Link(fst.Tmp+"/etc/NIXOS", "/etc/NIXOS"). Symlink(fst.Tmp+"/etc/netgroup", "/etc/netgroup").
Link(fst.Tmp+"/etc/nscd.conf", "/etc/nscd.conf"). Symlink(fst.Tmp+"/etc/NetworkManager", "/etc/NetworkManager").
Link(fst.Tmp+"/etc/nsswitch.conf", "/etc/nsswitch.conf"). Symlink(fst.Tmp+"/etc/nix", "/etc/nix").
Link(fst.Tmp+"/etc/opensnitchd", "/etc/opensnitchd"). Symlink(fst.Tmp+"/etc/nixos", "/etc/nixos").
Link(fst.Tmp+"/etc/os-release", "/etc/os-release"). Symlink(fst.Tmp+"/etc/NIXOS", "/etc/NIXOS").
Link(fst.Tmp+"/etc/pam", "/etc/pam"). Symlink(fst.Tmp+"/etc/nscd.conf", "/etc/nscd.conf").
Link(fst.Tmp+"/etc/pam.d", "/etc/pam.d"). Symlink(fst.Tmp+"/etc/nsswitch.conf", "/etc/nsswitch.conf").
Link(fst.Tmp+"/etc/pipewire", "/etc/pipewire"). Symlink(fst.Tmp+"/etc/opensnitchd", "/etc/opensnitchd").
Link(fst.Tmp+"/etc/pki", "/etc/pki"). Symlink(fst.Tmp+"/etc/os-release", "/etc/os-release").
Link(fst.Tmp+"/etc/polkit-1", "/etc/polkit-1"). Symlink(fst.Tmp+"/etc/pam", "/etc/pam").
Link(fst.Tmp+"/etc/profile", "/etc/profile"). Symlink(fst.Tmp+"/etc/pam.d", "/etc/pam.d").
Link(fst.Tmp+"/etc/protocols", "/etc/protocols"). Symlink(fst.Tmp+"/etc/pipewire", "/etc/pipewire").
Link(fst.Tmp+"/etc/qemu", "/etc/qemu"). Symlink(fst.Tmp+"/etc/pki", "/etc/pki").
Link(fst.Tmp+"/etc/resolv.conf", "/etc/resolv.conf"). Symlink(fst.Tmp+"/etc/polkit-1", "/etc/polkit-1").
Link(fst.Tmp+"/etc/resolvconf.conf", "/etc/resolvconf.conf"). Symlink(fst.Tmp+"/etc/profile", "/etc/profile").
Link(fst.Tmp+"/etc/rpc", "/etc/rpc"). Symlink(fst.Tmp+"/etc/protocols", "/etc/protocols").
Link(fst.Tmp+"/etc/samba", "/etc/samba"). Symlink(fst.Tmp+"/etc/qemu", "/etc/qemu").
Link(fst.Tmp+"/etc/sddm.conf", "/etc/sddm.conf"). Symlink(fst.Tmp+"/etc/resolv.conf", "/etc/resolv.conf").
Link(fst.Tmp+"/etc/secureboot", "/etc/secureboot"). Symlink(fst.Tmp+"/etc/resolvconf.conf", "/etc/resolvconf.conf").
Link(fst.Tmp+"/etc/services", "/etc/services"). Symlink(fst.Tmp+"/etc/rpc", "/etc/rpc").
Link(fst.Tmp+"/etc/set-environment", "/etc/set-environment"). Symlink(fst.Tmp+"/etc/samba", "/etc/samba").
Link(fst.Tmp+"/etc/shadow", "/etc/shadow"). Symlink(fst.Tmp+"/etc/sddm.conf", "/etc/sddm.conf").
Link(fst.Tmp+"/etc/shells", "/etc/shells"). Symlink(fst.Tmp+"/etc/secureboot", "/etc/secureboot").
Link(fst.Tmp+"/etc/ssh", "/etc/ssh"). Symlink(fst.Tmp+"/etc/services", "/etc/services").
Link(fst.Tmp+"/etc/ssl", "/etc/ssl"). Symlink(fst.Tmp+"/etc/set-environment", "/etc/set-environment").
Link(fst.Tmp+"/etc/static", "/etc/static"). Symlink(fst.Tmp+"/etc/shadow", "/etc/shadow").
Link(fst.Tmp+"/etc/subgid", "/etc/subgid"). Symlink(fst.Tmp+"/etc/shells", "/etc/shells").
Link(fst.Tmp+"/etc/subuid", "/etc/subuid"). Symlink(fst.Tmp+"/etc/ssh", "/etc/ssh").
Link(fst.Tmp+"/etc/sudoers", "/etc/sudoers"). Symlink(fst.Tmp+"/etc/ssl", "/etc/ssl").
Link(fst.Tmp+"/etc/sysctl.d", "/etc/sysctl.d"). Symlink(fst.Tmp+"/etc/static", "/etc/static").
Link(fst.Tmp+"/etc/systemd", "/etc/systemd"). Symlink(fst.Tmp+"/etc/subgid", "/etc/subgid").
Link(fst.Tmp+"/etc/terminfo", "/etc/terminfo"). Symlink(fst.Tmp+"/etc/subuid", "/etc/subuid").
Link(fst.Tmp+"/etc/tmpfiles.d", "/etc/tmpfiles.d"). Symlink(fst.Tmp+"/etc/sudoers", "/etc/sudoers").
Link(fst.Tmp+"/etc/udev", "/etc/udev"). Symlink(fst.Tmp+"/etc/sysctl.d", "/etc/sysctl.d").
Link(fst.Tmp+"/etc/udisks2", "/etc/udisks2"). Symlink(fst.Tmp+"/etc/systemd", "/etc/systemd").
Link(fst.Tmp+"/etc/UPower", "/etc/UPower"). Symlink(fst.Tmp+"/etc/terminfo", "/etc/terminfo").
Link(fst.Tmp+"/etc/vconsole.conf", "/etc/vconsole.conf"). Symlink(fst.Tmp+"/etc/tmpfiles.d", "/etc/tmpfiles.d").
Link(fst.Tmp+"/etc/X11", "/etc/X11"). Symlink(fst.Tmp+"/etc/udev", "/etc/udev").
Link(fst.Tmp+"/etc/zfs", "/etc/zfs"). Symlink(fst.Tmp+"/etc/udisks2", "/etc/udisks2").
Link(fst.Tmp+"/etc/zinputrc", "/etc/zinputrc"). Symlink(fst.Tmp+"/etc/UPower", "/etc/UPower").
Link(fst.Tmp+"/etc/zoneinfo", "/etc/zoneinfo"). Symlink(fst.Tmp+"/etc/vconsole.conf", "/etc/vconsole.conf").
Link(fst.Tmp+"/etc/zprofile", "/etc/zprofile"). Symlink(fst.Tmp+"/etc/X11", "/etc/X11").
Link(fst.Tmp+"/etc/zshenv", "/etc/zshenv"). Symlink(fst.Tmp+"/etc/zfs", "/etc/zfs").
Link(fst.Tmp+"/etc/zshrc", "/etc/zshrc"). Symlink(fst.Tmp+"/etc/zinputrc", "/etc/zinputrc").
Tmpfs("/run/user", 4096, 0755). Symlink(fst.Tmp+"/etc/zoneinfo", "/etc/zoneinfo").
Tmpfs("/run/user/65534", 8388608, 0755). Symlink(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Bind("/tmp/fortify.1971/tmpdir/0", "/tmp", sandbox.BindWritable). Symlink(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Bind("/home/chronos", "/home/chronos", sandbox.BindWritable). Symlink(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Place("/etc/passwd", []byte("chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n")). Tmpfs("/run/user", 1048576).
Place("/etc/group", []byte("fortify:x:65534:\n")). Tmpfs("/run/user/65534", 8388608).
Tmpfs("/var/run/nscd", 8192, 0755), Bind("/tmp/fortify.1971/tmpdir/0", "/tmp", false, true).
}, Bind("/home/chronos", "/home/chronos", false, true).
CopyBind("/etc/passwd", []byte("chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n")).
CopyBind("/etc/group", []byte("fortify:x:65534:\n")).
Tmpfs("/var/run/nscd", 8192).
Bind("/run/wrappers/bin/fortify", "/.fortify/sbin/fortify").
Symlink("fortify", "/.fortify/sbin/init"),
}, },
{ {
"nixos permissive defaults chromium", new(stubNixOS), "nixos permissive defaults chromium", new(stubNixOS),
&fst.Config{ &fst.Config{
ID: "org.chromium.Chromium", ID: "org.chromium.Chromium",
Args: []string{"zsh", "-c", "exec chromium "}, Command: []string{"/run/current-system/sw/bin/zsh", "-c", "exec chromium "},
Confinement: fst.ConfinementConfig{ Confinement: fst.ConfinementConfig{
AppID: 9, AppID: 9,
Groups: []string{"video"}, Groups: []string{"video"},
@ -195,7 +201,7 @@ var testCasesPd = []sealTestCase{
}, },
Filter: true, Filter: true,
}, },
Enablements: system.EWayland | system.EDBus | system.EPulse, Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(),
}, },
}, },
fst.ID{ fst.ID{
@ -248,135 +254,141 @@ var testCasesPd = []sealTestCase{
}). }).
UpdatePerm("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", acl.Read, acl.Write). UpdatePerm("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", acl.Read, acl.Write).
UpdatePerm("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", acl.Read, acl.Write), UpdatePerm("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", acl.Read, acl.Write),
&sandbox.Params{ (&bwrap.Config{
Flags: sandbox.FAllowNet | sandbox.FAllowUserns | sandbox.FAllowTTY, Net: true,
Dir: "/home/chronos", UserNS: true,
Path: "/run/current-system/sw/bin/zsh", Chdir: "/home/chronos",
Args: []string{"zsh", "-c", "exec chromium "}, Clearenv: true,
Env: []string{ Syscall: new(bwrap.SyscallPolicy),
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus", SetEnv: map[string]string{
"DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket", "DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/65534/bus",
"HOME=/home/chronos", "DBUS_SYSTEM_BUS_ADDRESS": "unix:path=/run/dbus/system_bus_socket",
"PULSE_COOKIE=" + fst.Tmp + "/pulse-cookie", "HOME": "/home/chronos",
"PULSE_SERVER=unix:/run/user/65534/pulse/native", "PULSE_COOKIE": fst.Tmp + "/pulse-cookie",
"TERM=xterm-256color", "PULSE_SERVER": "unix:/run/user/65534/pulse/native",
"USER=chronos", "SHELL": "/run/current-system/sw/bin/zsh",
"WAYLAND_DISPLAY=wayland-0", "TERM": "xterm-256color",
"XDG_RUNTIME_DIR=/run/user/65534", "USER": "chronos",
"XDG_SESSION_CLASS=user", "WAYLAND_DISPLAY": "wayland-0",
"XDG_SESSION_TYPE=tty", "XDG_RUNTIME_DIR": "/run/user/65534",
}, "XDG_SESSION_CLASS": "user",
Ops: new(sandbox.Ops). "XDG_SESSION_TYPE": "tty",
Proc("/proc").
Tmpfs(fst.Tmp, 4096, 0755).
Dev("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", sandbox.BindWritable).
Bind("/boot", "/boot", sandbox.BindWritable).
Bind("/home", "/home", sandbox.BindWritable).
Bind("/lib", "/lib", sandbox.BindWritable).
Bind("/lib64", "/lib64", sandbox.BindWritable).
Bind("/nix", "/nix", sandbox.BindWritable).
Bind("/root", "/root", sandbox.BindWritable).
Bind("/run", "/run", sandbox.BindWritable).
Bind("/srv", "/srv", sandbox.BindWritable).
Bind("/sys", "/sys", sandbox.BindWritable).
Bind("/usr", "/usr", sandbox.BindWritable).
Bind("/var", "/var", sandbox.BindWritable).
Bind("/dev/dri", "/dev/dri", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional).
Bind("/dev/kvm", "/dev/kvm", sandbox.BindWritable|sandbox.BindDevice|sandbox.BindOptional).
Tmpfs("/run/user/1971", 8192, 0755).
Tmpfs("/run/dbus", 8192, 0755).
Bind("/etc", fst.Tmp+"/etc", 0).
Link(fst.Tmp+"/etc/alsa", "/etc/alsa").
Link(fst.Tmp+"/etc/bashrc", "/etc/bashrc").
Link(fst.Tmp+"/etc/binfmt.d", "/etc/binfmt.d").
Link(fst.Tmp+"/etc/dbus-1", "/etc/dbus-1").
Link(fst.Tmp+"/etc/default", "/etc/default").
Link(fst.Tmp+"/etc/ethertypes", "/etc/ethertypes").
Link(fst.Tmp+"/etc/fonts", "/etc/fonts").
Link(fst.Tmp+"/etc/fstab", "/etc/fstab").
Link(fst.Tmp+"/etc/fuse.conf", "/etc/fuse.conf").
Link(fst.Tmp+"/etc/host.conf", "/etc/host.conf").
Link(fst.Tmp+"/etc/hostid", "/etc/hostid").
Link(fst.Tmp+"/etc/hostname", "/etc/hostname").
Link(fst.Tmp+"/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Link(fst.Tmp+"/etc/hosts", "/etc/hosts").
Link(fst.Tmp+"/etc/inputrc", "/etc/inputrc").
Link(fst.Tmp+"/etc/ipsec.d", "/etc/ipsec.d").
Link(fst.Tmp+"/etc/issue", "/etc/issue").
Link(fst.Tmp+"/etc/kbd", "/etc/kbd").
Link(fst.Tmp+"/etc/libblockdev", "/etc/libblockdev").
Link(fst.Tmp+"/etc/locale.conf", "/etc/locale.conf").
Link(fst.Tmp+"/etc/localtime", "/etc/localtime").
Link(fst.Tmp+"/etc/login.defs", "/etc/login.defs").
Link(fst.Tmp+"/etc/lsb-release", "/etc/lsb-release").
Link(fst.Tmp+"/etc/lvm", "/etc/lvm").
Link(fst.Tmp+"/etc/machine-id", "/etc/machine-id").
Link(fst.Tmp+"/etc/man_db.conf", "/etc/man_db.conf").
Link(fst.Tmp+"/etc/modprobe.d", "/etc/modprobe.d").
Link(fst.Tmp+"/etc/modules-load.d", "/etc/modules-load.d").
Link("/proc/mounts", "/etc/mtab").
Link(fst.Tmp+"/etc/nanorc", "/etc/nanorc").
Link(fst.Tmp+"/etc/netgroup", "/etc/netgroup").
Link(fst.Tmp+"/etc/NetworkManager", "/etc/NetworkManager").
Link(fst.Tmp+"/etc/nix", "/etc/nix").
Link(fst.Tmp+"/etc/nixos", "/etc/nixos").
Link(fst.Tmp+"/etc/NIXOS", "/etc/NIXOS").
Link(fst.Tmp+"/etc/nscd.conf", "/etc/nscd.conf").
Link(fst.Tmp+"/etc/nsswitch.conf", "/etc/nsswitch.conf").
Link(fst.Tmp+"/etc/opensnitchd", "/etc/opensnitchd").
Link(fst.Tmp+"/etc/os-release", "/etc/os-release").
Link(fst.Tmp+"/etc/pam", "/etc/pam").
Link(fst.Tmp+"/etc/pam.d", "/etc/pam.d").
Link(fst.Tmp+"/etc/pipewire", "/etc/pipewire").
Link(fst.Tmp+"/etc/pki", "/etc/pki").
Link(fst.Tmp+"/etc/polkit-1", "/etc/polkit-1").
Link(fst.Tmp+"/etc/profile", "/etc/profile").
Link(fst.Tmp+"/etc/protocols", "/etc/protocols").
Link(fst.Tmp+"/etc/qemu", "/etc/qemu").
Link(fst.Tmp+"/etc/resolv.conf", "/etc/resolv.conf").
Link(fst.Tmp+"/etc/resolvconf.conf", "/etc/resolvconf.conf").
Link(fst.Tmp+"/etc/rpc", "/etc/rpc").
Link(fst.Tmp+"/etc/samba", "/etc/samba").
Link(fst.Tmp+"/etc/sddm.conf", "/etc/sddm.conf").
Link(fst.Tmp+"/etc/secureboot", "/etc/secureboot").
Link(fst.Tmp+"/etc/services", "/etc/services").
Link(fst.Tmp+"/etc/set-environment", "/etc/set-environment").
Link(fst.Tmp+"/etc/shadow", "/etc/shadow").
Link(fst.Tmp+"/etc/shells", "/etc/shells").
Link(fst.Tmp+"/etc/ssh", "/etc/ssh").
Link(fst.Tmp+"/etc/ssl", "/etc/ssl").
Link(fst.Tmp+"/etc/static", "/etc/static").
Link(fst.Tmp+"/etc/subgid", "/etc/subgid").
Link(fst.Tmp+"/etc/subuid", "/etc/subuid").
Link(fst.Tmp+"/etc/sudoers", "/etc/sudoers").
Link(fst.Tmp+"/etc/sysctl.d", "/etc/sysctl.d").
Link(fst.Tmp+"/etc/systemd", "/etc/systemd").
Link(fst.Tmp+"/etc/terminfo", "/etc/terminfo").
Link(fst.Tmp+"/etc/tmpfiles.d", "/etc/tmpfiles.d").
Link(fst.Tmp+"/etc/udev", "/etc/udev").
Link(fst.Tmp+"/etc/udisks2", "/etc/udisks2").
Link(fst.Tmp+"/etc/UPower", "/etc/UPower").
Link(fst.Tmp+"/etc/vconsole.conf", "/etc/vconsole.conf").
Link(fst.Tmp+"/etc/X11", "/etc/X11").
Link(fst.Tmp+"/etc/zfs", "/etc/zfs").
Link(fst.Tmp+"/etc/zinputrc", "/etc/zinputrc").
Link(fst.Tmp+"/etc/zoneinfo", "/etc/zoneinfo").
Link(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Link(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Link(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Tmpfs("/run/user", 4096, 0755).
Tmpfs("/run/user/65534", 8388608, 0755).
Bind("/tmp/fortify.1971/tmpdir/9", "/tmp", sandbox.BindWritable).
Bind("/home/chronos", "/home/chronos", sandbox.BindWritable).
Place("/etc/passwd", []byte("chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n")).
Place("/etc/group", []byte("fortify:x:65534:\n")).
Bind("/tmp/fortify.1971/wayland/ebf083d1b175911782d413369b64ce7c", "/run/user/65534/wayland-0", 0).
Bind("/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/pulse", "/run/user/65534/pulse/native", 0).
Place(fst.Tmp+"/pulse-cookie", nil).
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", "/run/user/65534/bus", 0).
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", "/run/dbus/system_bus_socket", 0).
Tmpfs("/var/run/nscd", 8192, 0755),
}, },
Chmod: make(bwrap.ChmodConfig),
DieWithParent: true,
AsInit: true,
}).SetUID(65534).SetGID(65534).
Procfs("/proc").
Tmpfs(fst.Tmp, 4096).
DevTmpfs("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", false, true).
Bind("/boot", "/boot", false, true).
Bind("/home", "/home", false, true).
Bind("/lib", "/lib", false, true).
Bind("/lib64", "/lib64", false, true).
Bind("/nix", "/nix", false, true).
Bind("/root", "/root", false, true).
Bind("/run", "/run", false, true).
Bind("/srv", "/srv", false, true).
Bind("/sys", "/sys", false, true).
Bind("/usr", "/usr", false, true).
Bind("/var", "/var", false, true).
Bind("/dev/dri", "/dev/dri", true, true, true).
Bind("/dev/kvm", "/dev/kvm", true, true, true).
Tmpfs("/run/user/1971", 8192).
Tmpfs("/run/dbus", 8192).
Bind("/etc", fst.Tmp+"/etc").
Symlink(fst.Tmp+"/etc/alsa", "/etc/alsa").
Symlink(fst.Tmp+"/etc/bashrc", "/etc/bashrc").
Symlink(fst.Tmp+"/etc/binfmt.d", "/etc/binfmt.d").
Symlink(fst.Tmp+"/etc/dbus-1", "/etc/dbus-1").
Symlink(fst.Tmp+"/etc/default", "/etc/default").
Symlink(fst.Tmp+"/etc/ethertypes", "/etc/ethertypes").
Symlink(fst.Tmp+"/etc/fonts", "/etc/fonts").
Symlink(fst.Tmp+"/etc/fstab", "/etc/fstab").
Symlink(fst.Tmp+"/etc/fuse.conf", "/etc/fuse.conf").
Symlink(fst.Tmp+"/etc/host.conf", "/etc/host.conf").
Symlink(fst.Tmp+"/etc/hostid", "/etc/hostid").
Symlink(fst.Tmp+"/etc/hostname", "/etc/hostname").
Symlink(fst.Tmp+"/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Symlink(fst.Tmp+"/etc/hosts", "/etc/hosts").
Symlink(fst.Tmp+"/etc/inputrc", "/etc/inputrc").
Symlink(fst.Tmp+"/etc/ipsec.d", "/etc/ipsec.d").
Symlink(fst.Tmp+"/etc/issue", "/etc/issue").
Symlink(fst.Tmp+"/etc/kbd", "/etc/kbd").
Symlink(fst.Tmp+"/etc/libblockdev", "/etc/libblockdev").
Symlink(fst.Tmp+"/etc/locale.conf", "/etc/locale.conf").
Symlink(fst.Tmp+"/etc/localtime", "/etc/localtime").
Symlink(fst.Tmp+"/etc/login.defs", "/etc/login.defs").
Symlink(fst.Tmp+"/etc/lsb-release", "/etc/lsb-release").
Symlink(fst.Tmp+"/etc/lvm", "/etc/lvm").
Symlink(fst.Tmp+"/etc/machine-id", "/etc/machine-id").
Symlink(fst.Tmp+"/etc/man_db.conf", "/etc/man_db.conf").
Symlink(fst.Tmp+"/etc/modprobe.d", "/etc/modprobe.d").
Symlink(fst.Tmp+"/etc/modules-load.d", "/etc/modules-load.d").
Symlink("/proc/mounts", "/etc/mtab").
Symlink(fst.Tmp+"/etc/nanorc", "/etc/nanorc").
Symlink(fst.Tmp+"/etc/netgroup", "/etc/netgroup").
Symlink(fst.Tmp+"/etc/NetworkManager", "/etc/NetworkManager").
Symlink(fst.Tmp+"/etc/nix", "/etc/nix").
Symlink(fst.Tmp+"/etc/nixos", "/etc/nixos").
Symlink(fst.Tmp+"/etc/NIXOS", "/etc/NIXOS").
Symlink(fst.Tmp+"/etc/nscd.conf", "/etc/nscd.conf").
Symlink(fst.Tmp+"/etc/nsswitch.conf", "/etc/nsswitch.conf").
Symlink(fst.Tmp+"/etc/opensnitchd", "/etc/opensnitchd").
Symlink(fst.Tmp+"/etc/os-release", "/etc/os-release").
Symlink(fst.Tmp+"/etc/pam", "/etc/pam").
Symlink(fst.Tmp+"/etc/pam.d", "/etc/pam.d").
Symlink(fst.Tmp+"/etc/pipewire", "/etc/pipewire").
Symlink(fst.Tmp+"/etc/pki", "/etc/pki").
Symlink(fst.Tmp+"/etc/polkit-1", "/etc/polkit-1").
Symlink(fst.Tmp+"/etc/profile", "/etc/profile").
Symlink(fst.Tmp+"/etc/protocols", "/etc/protocols").
Symlink(fst.Tmp+"/etc/qemu", "/etc/qemu").
Symlink(fst.Tmp+"/etc/resolv.conf", "/etc/resolv.conf").
Symlink(fst.Tmp+"/etc/resolvconf.conf", "/etc/resolvconf.conf").
Symlink(fst.Tmp+"/etc/rpc", "/etc/rpc").
Symlink(fst.Tmp+"/etc/samba", "/etc/samba").
Symlink(fst.Tmp+"/etc/sddm.conf", "/etc/sddm.conf").
Symlink(fst.Tmp+"/etc/secureboot", "/etc/secureboot").
Symlink(fst.Tmp+"/etc/services", "/etc/services").
Symlink(fst.Tmp+"/etc/set-environment", "/etc/set-environment").
Symlink(fst.Tmp+"/etc/shadow", "/etc/shadow").
Symlink(fst.Tmp+"/etc/shells", "/etc/shells").
Symlink(fst.Tmp+"/etc/ssh", "/etc/ssh").
Symlink(fst.Tmp+"/etc/ssl", "/etc/ssl").
Symlink(fst.Tmp+"/etc/static", "/etc/static").
Symlink(fst.Tmp+"/etc/subgid", "/etc/subgid").
Symlink(fst.Tmp+"/etc/subuid", "/etc/subuid").
Symlink(fst.Tmp+"/etc/sudoers", "/etc/sudoers").
Symlink(fst.Tmp+"/etc/sysctl.d", "/etc/sysctl.d").
Symlink(fst.Tmp+"/etc/systemd", "/etc/systemd").
Symlink(fst.Tmp+"/etc/terminfo", "/etc/terminfo").
Symlink(fst.Tmp+"/etc/tmpfiles.d", "/etc/tmpfiles.d").
Symlink(fst.Tmp+"/etc/udev", "/etc/udev").
Symlink(fst.Tmp+"/etc/udisks2", "/etc/udisks2").
Symlink(fst.Tmp+"/etc/UPower", "/etc/UPower").
Symlink(fst.Tmp+"/etc/vconsole.conf", "/etc/vconsole.conf").
Symlink(fst.Tmp+"/etc/X11", "/etc/X11").
Symlink(fst.Tmp+"/etc/zfs", "/etc/zfs").
Symlink(fst.Tmp+"/etc/zinputrc", "/etc/zinputrc").
Symlink(fst.Tmp+"/etc/zoneinfo", "/etc/zoneinfo").
Symlink(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Symlink(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Symlink(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/65534", 8388608).
Bind("/tmp/fortify.1971/tmpdir/9", "/tmp", false, true).
Bind("/home/chronos", "/home/chronos", false, true).
CopyBind("/etc/passwd", []byte("chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n")).
CopyBind("/etc/group", []byte("fortify:x:65534:\n")).
Bind("/tmp/fortify.1971/wayland/ebf083d1b175911782d413369b64ce7c", "/run/user/65534/wayland-0").
Bind("/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/pulse", "/run/user/65534/pulse/native").
CopyBind(fst.Tmp+"/pulse-cookie", nil).
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", "/run/user/65534/bus").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", "/run/dbus/system_bus_socket").
Tmpfs("/var/run/nscd", 8192).
Bind("/run/wrappers/bin/fortify", "/.fortify/sbin/fortify").
Symlink("fortify", "/.fortify/sbin/init"),
}, },
} }

View File

@ -17,8 +17,7 @@ type stubNixOS struct {
usernameErr map[string]error usernameErr map[string]error
} }
func (s *stubNixOS) Getuid() int { return 1971 } func (s *stubNixOS) Geteuid() int { return 1971 }
func (s *stubNixOS) Getgid() int { return 100 }
func (s *stubNixOS) TempDir() string { return "/tmp" } func (s *stubNixOS) TempDir() string { return "/tmp" }
func (s *stubNixOS) MustExecutable() string { return "/run/wrappers/bin/fortify" } func (s *stubNixOS) MustExecutable() string { return "/run/wrappers/bin/fortify" }
func (s *stubNixOS) Exit(code int) { panic("called exit on stub with code " + strconv.Itoa(code)) } func (s *stubNixOS) Exit(code int) { panic("called exit on stub with code " + strconv.Itoa(code)) }
@ -55,8 +54,10 @@ func (s *stubNixOS) LookPath(file string) (string, error) {
} }
switch file { switch file {
case "zsh": case "sudo":
return "/run/current-system/sw/bin/zsh", nil return "/run/wrappers/bin/sudo", nil
case "machinectl":
return "/home/ophestra/.nix-profile/bin/machinectl", nil
default: default:
panic(fmt.Sprintf("attempted to look up unexpected executable %q", file)) panic(fmt.Sprintf("attempted to look up unexpected executable %q", file))
} }

View File

@ -8,9 +8,9 @@ import (
"time" "time"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/app" "git.gensokyo.uk/security/fortify/internal/app"
"git.gensokyo.uk/security/fortify/internal/sys" "git.gensokyo.uk/security/fortify/internal/sys"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/system" "git.gensokyo.uk/security/fortify/system"
) )
@ -20,7 +20,7 @@ type sealTestCase struct {
config *fst.Config config *fst.Config
id fst.ID id fst.ID
wantSys *system.I wantSys *system.I
wantContainer *sandbox.Params wantBwrap *bwrap.Config
} }
func TestApp(t *testing.T) { func TestApp(t *testing.T) {
@ -31,14 +31,14 @@ func TestApp(t *testing.T) {
a := app.NewWithID(tc.id, tc.os) a := app.NewWithID(tc.id, tc.os)
var ( var (
gotSys *system.I gotSys *system.I
gotContainer *sandbox.Params gotBwrap *bwrap.Config
) )
if !t.Run("seal", func(t *testing.T) { if !t.Run("seal", func(t *testing.T) {
if sa, err := a.Seal(tc.config); err != nil { if sa, err := a.Seal(tc.config); err != nil {
t.Errorf("Seal: error = %v", err) t.Errorf("Seal: error = %v", err)
return return
} else { } else {
gotSys, gotContainer = app.AppIParams(a, sa) gotSys, gotBwrap = app.AppSystemBwrap(a, sa)
} }
}) { }) {
return return
@ -51,10 +51,10 @@ func TestApp(t *testing.T) {
} }
}) })
t.Run("compare params", func(t *testing.T) { t.Run("compare bwrap", func(t *testing.T) {
if !reflect.DeepEqual(gotContainer, tc.wantContainer) { if !reflect.DeepEqual(gotBwrap, tc.wantBwrap) {
t.Errorf("seal: params =\n%s\n, want\n%s", t.Errorf("seal: bwrap =\n%s\n, want\n%s",
mustMarshal(gotContainer), mustMarshal(tc.wantContainer)) mustMarshal(gotBwrap), mustMarshal(tc.wantBwrap))
} }
}) })
}) })

View File

@ -1,179 +0,0 @@
package app
import (
"errors"
"log"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
func PrintRunStateErr(rs *fst.RunState, runErr error) {
if runErr != nil {
if rs.Time == nil {
fmsg.PrintBaseError(runErr, "cannot start app:")
} else {
var e *fmsg.BaseError
if !fmsg.AsBaseError(runErr, &e) {
log.Println("wait failed:", runErr)
} else {
// Wait only returns either *app.ProcessError or *app.StateStoreError wrapped in a *app.BaseError
var se *StateStoreError
if !errors.As(runErr, &se) {
// does not need special handling
log.Print(e.Message())
} else {
// inner error are either unwrapped store errors
// or joined errors returned by *appSealTx revert
// wrapped in *app.BaseError
var ej RevertCompoundError
if !errors.As(se.InnerErr, &ej) {
// does not require special handling
log.Print(e.Message())
} else {
errs := ej.Unwrap()
// every error here is wrapped in *app.BaseError
for _, ei := range errs {
var eb *fmsg.BaseError
if !errors.As(ei, &eb) {
// unreachable
log.Println("invalid error type returned by revert:", ei)
} else {
// print inner *app.BaseError message
log.Print(eb.Message())
}
}
}
}
}
}
if rs.ExitCode == 0 {
rs.ExitCode = 126
}
}
if rs.RevertErr != nil {
var stateStoreError *StateStoreError
if !errors.As(rs.RevertErr, &stateStoreError) || stateStoreError == nil {
fmsg.PrintBaseError(rs.RevertErr, "generic fault during cleanup:")
goto out
}
if stateStoreError.Err != nil {
if len(stateStoreError.Err) == 2 {
if stateStoreError.Err[0] != nil {
if joinedErrs, ok := stateStoreError.Err[0].(interface{ Unwrap() []error }); !ok {
fmsg.PrintBaseError(stateStoreError.Err[0], "generic fault during revert:")
} else {
for _, err := range joinedErrs.Unwrap() {
if err != nil {
fmsg.PrintBaseError(err, "fault during revert:")
}
}
}
}
if stateStoreError.Err[1] != nil {
log.Printf("cannot close store: %v", stateStoreError.Err[1])
}
} else {
log.Printf("fault during cleanup: %v",
errors.Join(stateStoreError.Err...))
}
}
if stateStoreError.OpErr != nil {
log.Printf("blind revert due to store fault: %v",
stateStoreError.OpErr)
}
if stateStoreError.DoErr != nil {
fmsg.PrintBaseError(stateStoreError.DoErr, "state store operation unsuccessful:")
}
if stateStoreError.Inner && stateStoreError.InnerErr != nil {
fmsg.PrintBaseError(stateStoreError.InnerErr, "cannot destroy state entry:")
}
out:
if rs.ExitCode == 0 {
rs.ExitCode = 128
}
}
if rs.WaitErr != nil {
log.Println("inner wait failed:", rs.WaitErr)
}
}
// StateStoreError is returned for a failed state save
type StateStoreError struct {
// whether inner function was called
Inner bool
// returned by the Save/Destroy method of [state.Cursor]
InnerErr error
// returned by the Do method of [state.Store]
DoErr error
// stores an arbitrary store operation error
OpErr error
// stores arbitrary errors
Err []error
}
// save saves arbitrary errors in [StateStoreError] once.
func (e *StateStoreError) save(errs []error) {
if len(errs) == 0 || e.Err != nil {
panic("invalid call to save")
}
e.Err = errs
}
func (e *StateStoreError) equiv(a ...any) error {
if e.Inner && e.InnerErr == nil && e.DoErr == nil && e.OpErr == nil && errors.Join(e.Err...) == nil {
return nil
} else {
return fmsg.WrapErrorSuffix(e, a...)
}
}
func (e *StateStoreError) Error() string {
if e.Inner && e.InnerErr != nil {
return e.InnerErr.Error()
}
if e.DoErr != nil {
return e.DoErr.Error()
}
if e.OpErr != nil {
return e.OpErr.Error()
}
if err := errors.Join(e.Err...); err != nil {
return err.Error()
}
// equiv nullifies e for values where this is reached
panic("unreachable")
}
func (e *StateStoreError) Unwrap() (errs []error) {
errs = make([]error, 0, 3)
if e.InnerErr != nil {
errs = append(errs, e.InnerErr)
}
if e.DoErr != nil {
errs = append(errs, e.DoErr)
}
if e.OpErr != nil {
errs = append(errs, e.OpErr)
}
if err := errors.Join(e.Err...); err != nil {
errs = append(errs, err)
}
return
}
// A RevertCompoundError encapsulates errors returned by
// the Revert method of [system.I].
type RevertCompoundError interface {
Error() string
Unwrap() []error
}

View File

@ -2,8 +2,8 @@ package app
import ( import (
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/sys" "git.gensokyo.uk/security/fortify/internal/sys"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/system" "git.gensokyo.uk/security/fortify/system"
) )
@ -14,7 +14,7 @@ func NewWithID(id fst.ID, os sys.State) fst.App {
return a return a
} }
func AppIParams(a fst.App, sa fst.SealedApp) (*system.I, *sandbox.Params) { func AppSystemBwrap(a fst.App, sa fst.SealedApp) (*system.I, *bwrap.Config) {
v := a.(*app) v := a.(*app)
seal := sa.(*outcome) seal := sa.(*outcome)
if v.outcome != seal || v.id != seal.id { if v.outcome != seal || v.id != seal.id {

View File

@ -0,0 +1,18 @@
package init0
import (
"os"
"path"
"git.gensokyo.uk/security/fortify/internal"
)
// used by the parent process
// TryArgv0 calls [Main] if argv0 indicates the process is started from a file named "init".
func TryArgv0() {
if len(os.Args) > 0 && path.Base(os.Args[0]) == "init" {
Main()
internal.Exit(0)
}
}

165
internal/app/init/main.go Normal file
View File

@ -0,0 +1,165 @@
package init0
import (
"errors"
"log"
"os"
"os/exec"
"os/signal"
"syscall"
"time"
"git.gensokyo.uk/security/fortify/helper/proc"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
const (
// time to wait for linger processes after death of initial process
residualProcessTimeout = 5 * time.Second
)
// everything beyond this point runs within pid namespace
// proceed with caution!
func Main() {
// sharing stdout with shim
// USE WITH CAUTION
fmsg.Prepare("init")
// setting this prevents ptrace
if err := internal.PR_SET_DUMPABLE__SUID_DUMP_DISABLE(); err != nil {
log.Fatalf("cannot set SUID_DUMP_DISABLE: %s", err)
}
if os.Getpid() != 1 {
log.Fatal("this process must run as pid 1")
}
// receive setup payload
var (
payload Payload
closeSetup func() error
)
if f, err := proc.Receive(Env, &payload); err != nil {
if errors.Is(err, proc.ErrInvalid) {
log.Fatal("invalid config descriptor")
}
if errors.Is(err, proc.ErrNotSet) {
log.Fatal("FORTIFY_INIT not set")
}
log.Fatalf("cannot decode init setup payload: %v", err)
} else {
fmsg.Store(payload.Verbose)
closeSetup = f
// child does not need to see this
if err = os.Unsetenv(Env); err != nil {
log.Printf("cannot unset %s: %v", Env, err)
// not fatal
} else {
fmsg.Verbose("received configuration")
}
}
// die with parent
if err := internal.PR_SET_PDEATHSIG__SIGKILL(); err != nil {
log.Fatalf("prctl(PR_SET_PDEATHSIG, SIGKILL): %v", err)
}
cmd := exec.Command(payload.Argv0)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Args = payload.Argv
cmd.Env = os.Environ()
if err := cmd.Start(); err != nil {
log.Fatalf("cannot start %q: %v", payload.Argv0, err)
}
fmsg.Suspend()
// close setup pipe as setup is now complete
if err := closeSetup(); err != nil {
log.Println("cannot close setup pipe:", err)
// not fatal
}
sig := make(chan os.Signal, 2)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
type winfo struct {
wpid int
wstatus syscall.WaitStatus
}
info := make(chan winfo, 1)
done := make(chan struct{})
go func() {
var (
err error
wpid = -2
wstatus syscall.WaitStatus
)
// keep going until no child process is left
for wpid != -1 {
if err != nil {
break
}
if wpid != -2 {
info <- winfo{wpid, wstatus}
}
err = syscall.EINTR
for errors.Is(err, syscall.EINTR) {
wpid, err = syscall.Wait4(-1, &wstatus, 0, nil)
}
}
if !errors.Is(err, syscall.ECHILD) {
log.Println("unexpected wait4 response:", err)
}
close(done)
}()
// closed after residualProcessTimeout has elapsed after initial process death
timeout := make(chan struct{})
r := 2
for {
select {
case s := <-sig:
if fmsg.Resume() {
fmsg.Verbosef("terminating on %s after process start", s.String())
} else {
fmsg.Verbosef("terminating on %s", s.String())
}
internal.Exit(0)
case w := <-info:
if w.wpid == cmd.Process.Pid {
// initial process exited, output is most likely available again
fmsg.Resume()
switch {
case w.wstatus.Exited():
r = w.wstatus.ExitStatus()
case w.wstatus.Signaled():
r = 128 + int(w.wstatus.Signal())
default:
r = 255
}
go func() {
time.Sleep(residualProcessTimeout)
close(timeout)
}()
}
case <-done:
internal.Exit(r)
case <-timeout:
log.Println("timeout exceeded waiting for lingering processes")
internal.Exit(r)
}
}
}

View File

@ -0,0 +1,13 @@
package init0
const Env = "FORTIFY_INIT"
type Payload struct {
// target full exec path
Argv0 string
// child full argv
Argv []string
// verbosity pass through
Verbose bool
}

View File

@ -3,12 +3,16 @@ package app
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log" "log"
"os/exec" "os/exec"
"path/filepath"
"strings"
"time" "time"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal" "git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/internal/app/shim"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/state" "git.gensokyo.uk/security/fortify/internal/state"
"git.gensokyo.uk/security/fortify/system" "git.gensokyo.uk/security/fortify/system"
@ -16,7 +20,7 @@ import (
const shimSetupTimeout = 5 * time.Second const shimSetupTimeout = 5 * time.Second
func (seal *outcome) Run(rs *fst.RunState) error { func (seal *outcome) Run(ctx context.Context, rs *fst.RunState) error {
if !seal.f.CompareAndSwap(false, true) { if !seal.f.CompareAndSwap(false, true) {
// run does much more than just starting a process; calling it twice, even if the first call fails, will result // run does much more than just starting a process; calling it twice, even if the first call fails, will result
// in inconsistent state that is impossible to clean up; return here to limit damage and hopefully give the // in inconsistent state that is impossible to clean up; return here to limit damage and hopefully give the
@ -28,15 +32,33 @@ func (seal *outcome) Run(rs *fst.RunState) error {
panic("invalid state") panic("invalid state")
} }
// read comp values early to allow for early failure /*
fmsg.Verbosef("version %s", internal.Version()) resolve exec paths
fmsg.Verbosef("setuid helper at %s", internal.MustFsuPath()) */
shimExec := [2]string{helper.BubblewrapName}
if len(seal.command) > 0 {
shimExec[1] = seal.command[0]
}
for i, n := range shimExec {
if len(n) == 0 {
continue
}
if filepath.Base(n) == n {
if s, err := exec.LookPath(n); err == nil {
shimExec[i] = s
} else {
return fmsg.WrapError(err,
fmt.Sprintf("executable file %q not found in $PATH", n))
}
}
}
/* /*
prepare/revert os state prepare/revert os state
*/ */
if err := seal.sys.Commit(seal.ctx); err != nil { if err := seal.sys.Commit(ctx); err != nil {
return err return err
} }
store := state.NewMulti(seal.runDirPath) store := state.NewMulti(seal.runDirPath)
@ -52,16 +74,16 @@ func (seal *outcome) Run(rs *fst.RunState) error {
revert app setup transaction revert app setup transaction
*/ */
var rt system.Enablement rt, ec := new(system.Enablements), new(system.Criteria)
ec := system.Process ec.Enablements = new(system.Enablements)
ec.Set(system.Process)
if states, err := c.Load(); err != nil { if states, err := c.Load(); err != nil {
// revert per-process state here to limit damage // revert per-process state here to limit damage
storeErr.OpErr = err return errors.Join(err, seal.sys.Revert(ec))
return seal.sys.Revert((*system.Criteria)(&ec))
} else { } else {
if l := len(states); l == 0 { if l := len(states); l == 0 {
fmsg.Verbose("no other launchers active, will clean up globals") fmsg.Verbose("no other launchers active, will clean up globals")
ec |= system.User ec.Set(system.User)
} else { } else {
fmsg.Verbosef("found %d active launchers, cleaning up without globals", l) fmsg.Verbosef("found %d active launchers, cleaning up without globals", l)
} }
@ -69,23 +91,38 @@ func (seal *outcome) Run(rs *fst.RunState) error {
// accumulate enablements of remaining launchers // accumulate enablements of remaining launchers
for i, s := range states { for i, s := range states {
if s.Config != nil { if s.Config != nil {
rt |= s.Config.Confinement.Enablements *rt |= s.Config.Confinement.Enablements
} else { } else {
log.Printf("state entry %d does not contain config", i) log.Printf("state entry %d does not contain config", i)
} }
} }
} }
ec |= rt ^ (system.EWayland | system.EX11 | system.EDBus | system.EPulse) // invert accumulated enablements for cleanup
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if !rt.Has(i) {
ec.Set(i)
}
}
if fmsg.Load() { if fmsg.Load() {
if ec > 0 { labels := make([]string, 0, system.ELen+1)
fmsg.Verbose("reverting operations type", system.TypeString(ec)) for i := system.Enablement(0); i < system.Enablement(system.ELen+2); i++ {
if ec.Has(i) {
labels = append(labels, system.TypeString(i))
}
}
if len(labels) > 0 {
fmsg.Verbose("reverting operations type", strings.Join(labels, ", "))
} }
} }
return seal.sys.Revert((*system.Criteria)(&ec)) err := seal.sys.Revert(ec)
if err != nil {
err = err.(RevertCompoundError)
}
return err
}() }()
}) })
storeErr.save([]error{revertErr, store.Close()}) storeErr.Err = errors.Join(revertErr, store.Close())
rs.RevertErr = storeErr.equiv("error returned during cleanup:") rs.RevertErr = storeErr.equiv("error returned during cleanup:")
}() }()
@ -94,10 +131,11 @@ func (seal *outcome) Run(rs *fst.RunState) error {
*/ */
waitErr := make(chan error, 1) waitErr := make(chan error, 1)
cmd := new(shimProcess) cmd := new(shim.Shim)
if startTime, err := cmd.Start( if startTime, err := cmd.Start(
seal.user.aid.String(), seal.user.aid.String(),
seal.user.supp, seal.user.supp,
seal.bwrapSync,
); err != nil { ); err != nil {
return err return err
} else { } else {
@ -105,7 +143,7 @@ func (seal *outcome) Run(rs *fst.RunState) error {
rs.Time = startTime rs.Time = startTime
} }
ctx, cancel := context.WithTimeout(seal.ctx, shimSetupTimeout) c, cancel := context.WithTimeout(ctx, shimSetupTimeout)
defer cancel() defer cancel()
go func() { go func() {
@ -114,8 +152,10 @@ func (seal *outcome) Run(rs *fst.RunState) error {
cancel() cancel()
}() }()
if err := cmd.Serve(ctx, &shimParams{ if err := cmd.Serve(c, &shim.Payload{
Container: seal.container, Argv: seal.command,
Exec: shimExec,
Bwrap: seal.container,
Home: seal.user.data, Home: seal.user.data,
Verbose: fmsg.Load(), Verbose: fmsg.Load(),
@ -130,9 +170,7 @@ func (seal *outcome) Run(rs *fst.RunState) error {
Time: *rs.Time, Time: *rs.Time,
} }
var earlyStoreErr = new(StateStoreError) // returned after blocking on waitErr var earlyStoreErr = new(StateStoreError) // returned after blocking on waitErr
earlyStoreErr.Inner, earlyStoreErr.DoErr = store.Do(seal.user.aid.unwrap(), func(c state.Cursor) { earlyStoreErr.Inner, earlyStoreErr.DoErr = store.Do(seal.user.aid.unwrap(), func(c state.Cursor) { earlyStoreErr.InnerErr = c.Save(&sd, seal.ct) })
earlyStoreErr.InnerErr = c.Save(&sd, seal.ct)
})
// destroy defunct state entry // destroy defunct state entry
deferredStoreFunc = func(c state.Cursor) error { return c.Destroy(seal.id.unwrap()) } deferredStoreFunc = func(c state.Cursor) error { return c.Destroy(seal.id.unwrap()) }
@ -157,24 +195,86 @@ func (seal *outcome) Run(rs *fst.RunState) error {
// this is reached when a fault makes an already running shim impossible to continue execution // this is reached when a fault makes an already running shim impossible to continue execution
// however a kill signal could not be delivered (should actually always happen like that since fsu) // however a kill signal could not be delivered (should actually always happen like that since fsu)
// the effects of this is similar to the alternative exit path and ensures shim death // the effects of this is similar to the alternative exit path and ensures shim death
case err := <-cmd.Fallback(): case err := <-cmd.WaitFallback():
rs.ExitCode = 255 rs.ExitCode = 255
log.Printf("cannot terminate shim on faulted setup: %v", err) log.Printf("cannot terminate shim on faulted setup: %v", err)
// alternative exit path relying on shim behaviour on monitor process exit // alternative exit path relying on shim behaviour on monitor process exit
case <-seal.ctx.Done(): case <-ctx.Done():
fmsg.Verbose("alternative exit path selected") fmsg.Verbose("alternative exit path selected")
} }
fmsg.Resume() fmsg.Resume()
if seal.sync != nil {
if err := seal.sync.Close(); err != nil {
log.Printf("cannot close wayland security context: %v", err)
}
}
if seal.dbusMsg != nil { if seal.dbusMsg != nil {
// dump dbus message buffer
seal.dbusMsg() seal.dbusMsg()
} }
return earlyStoreErr.equiv("cannot save process state:") return earlyStoreErr.equiv("cannot save process state:")
} }
// StateStoreError is returned for a failed state save
type StateStoreError struct {
// whether inner function was called
Inner bool
// returned by the Do method of [state.Store]
DoErr error
// returned by the Save/Destroy method of [state.Cursor]
InnerErr error
// stores an arbitrary error
Err error
}
// save saves exactly one arbitrary error in [StateStoreError].
func (e *StateStoreError) save(err error) {
if err == nil || e.Err != nil {
panic("invalid call to save")
}
e.Err = err
}
func (e *StateStoreError) equiv(a ...any) error {
if e.Inner && e.DoErr == nil && e.InnerErr == nil && e.Err == nil {
return nil
} else {
return fmsg.WrapErrorSuffix(e, a...)
}
}
func (e *StateStoreError) Error() string {
if e.Inner && e.InnerErr != nil {
return e.InnerErr.Error()
}
if e.DoErr != nil {
return e.DoErr.Error()
}
if e.Err != nil {
return e.Err.Error()
}
// equiv nullifies e for values where this is reached
panic("unreachable")
}
func (e *StateStoreError) Unwrap() (errs []error) {
errs = make([]error, 0, 3)
if e.DoErr != nil {
errs = append(errs, e.DoErr)
}
if e.InnerErr != nil {
errs = append(errs, e.InnerErr)
}
if e.Err != nil {
errs = append(errs, e.Err)
}
return
}
// A RevertCompoundError encapsulates errors returned by
// the Revert method of [system.I].
type RevertCompoundError interface {
Error() string
Unwrap() []error
}

View File

@ -2,30 +2,26 @@ package app
import ( import (
"bytes" "bytes"
"context"
"encoding/gob" "encoding/gob"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"maps"
"os" "os"
"path" "path"
"regexp" "regexp"
"slices"
"strings" "strings"
"sync/atomic" "sync/atomic"
"syscall"
"git.gensokyo.uk/security/fortify/acl" "git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst" "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"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/sys" "git.gensokyo.uk/security/fortify/internal/sys"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/sandbox/wl"
"git.gensokyo.uk/security/fortify/system" "git.gensokyo.uk/security/fortify/system"
"git.gensokyo.uk/security/fortify/wl"
) )
const ( const (
@ -69,19 +65,19 @@ type outcome struct {
// copied from [sys.State] response // copied from [sys.State] response
runDirPath string runDirPath string
// passed through from [fst.Config]
command []string
// initial [fst.Config] gob stream for state data; // initial [fst.Config] gob stream for state data;
// this is prepared ahead of time as config is clobbered during seal creation // this is prepared ahead of time as config is mutated during seal creation
ct io.WriterTo ct io.WriterTo
// dump dbus proxy message buffer // dump dbus proxy message buffer
dbusMsg func() dbusMsg func()
user fsuUser user fsuUser
sys *system.I sys *system.I
ctx context.Context container *bwrap.Config
bwrapSync *os.File
container *sandbox.Params
env map[string]string
sync *os.File
f atomic.Bool f atomic.Bool
} }
@ -104,17 +100,7 @@ type fsuUser struct {
username string username string
} }
func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Config) error { func (seal *outcome) finalise(sys sys.State, config *fst.Config) error {
if seal.ctx != nil {
panic("finalise called twice")
}
seal.ctx = ctx
shellPath := "/bin/sh"
if s, ok := sys.LookupEnv(shell); ok && path.IsAbs(s) {
shellPath = s
}
{ {
// encode initial configuration for state tracking // encode initial configuration for state tracking
ct := new(bytes.Buffer) ct := new(bytes.Buffer)
@ -125,6 +111,9 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
seal.ct = ct seal.ct = ct
} }
// pass through command slice; this value is never touched in the main process
seal.command = config.Command
// allowed aid range 0 to 9999, this is checked again in fsu // allowed aid range 0 to 9999, this is checked again in fsu
if config.Confinement.AppID < 0 || config.Confinement.AppID > 9999 { if config.Confinement.AppID < 0 || config.Confinement.AppID > 9999 {
return fmsg.WrapError(ErrUser, return fmsg.WrapError(ErrUser,
@ -178,23 +167,11 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
if config.Confinement.Sandbox == nil { if config.Confinement.Sandbox == nil {
fmsg.Verbose("sandbox configuration not supplied, PROCEED WITH CAUTION") fmsg.Verbose("sandbox configuration not supplied, PROCEED WITH CAUTION")
// fsu clears the environment so resolve paths early
if !path.IsAbs(config.Path) {
if len(config.Args) > 0 {
if p, err := sys.LookPath(config.Args[0]); err != nil {
return fmsg.WrapError(err, err.Error())
} else {
config.Path = p
}
} else {
config.Path = shellPath
}
}
conf := &fst.SandboxConfig{ conf := &fst.SandboxConfig{
Userns: true, UserNS: true,
Net: true, Net: true,
Tty: true, Syscall: new(bwrap.SyscallPolicy),
NoNewSession: true,
AutoEtc: true, AutoEtc: true,
} }
// bind entries in / // bind entries in /
@ -221,10 +198,10 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
// hide nscd from sandbox if present // hide nscd from sandbox if present
nscd := "/var/run/nscd" nscd := "/var/run/nscd"
if _, err := sys.Stat(nscd); !errors.Is(err, fs.ErrNotExist) { if _, err := sys.Stat(nscd); !errors.Is(err, fs.ErrNotExist) {
conf.Cover = append(conf.Cover, nscd) conf.Override = append(conf.Override, nscd)
} }
// bind GPU stuff // bind GPU stuff
if config.Confinement.Enablements&(system.EX11|system.EWayland) != 0 { if config.Confinement.Enablements.Has(system.EX11) || config.Confinement.Enablements.Has(system.EWayland) {
conf.Filesystem = append(conf.Filesystem, &fst.FilesystemConfig{Src: "/dev/dri", Device: true}) conf.Filesystem = append(conf.Filesystem, &fst.FilesystemConfig{Src: "/dev/dri", Device: true})
} }
// opportunistically bind kvm // opportunistically bind kvm
@ -233,29 +210,17 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
config.Confinement.Sandbox = conf config.Confinement.Sandbox = conf
} }
var mapuid, mapgid *stringPair[int] var mapuid *stringPair[int]
{ {
var uid, gid int var uid int
var err error var err error
seal.container, seal.env, err = config.Confinement.Sandbox.ToContainer(sys, &uid, &gid) seal.container, err = config.Confinement.Sandbox.Bwrap(sys, &uid)
if err != nil { if err != nil {
return fmsg.WrapErrorSuffix(err, return err
"cannot initialise container configuration:")
} }
if !path.IsAbs(config.Path) {
return fmsg.WrapError(syscall.EINVAL,
"invalid program path")
}
if len(config.Args) == 0 {
config.Args = []string{config.Path}
}
seal.container.Path = config.Path
seal.container.Args = config.Args
mapuid = newInt(uid) mapuid = newInt(uid)
mapgid = newInt(gid) if seal.container.SetEnv == nil {
if seal.env == nil { seal.container.SetEnv = make(map[string]string)
seal.env = make(map[string]string)
} }
} }
@ -266,6 +231,10 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
sc := sys.Paths() sc := sys.Paths()
seal.runDirPath = sc.RunDirPath seal.runDirPath = sc.RunDirPath
seal.sys = system.New(seal.user.uid.unwrap()) seal.sys = system.New(seal.user.uid.unwrap())
seal.sys.IsVerbose = fmsg.Load
seal.sys.Verbose = fmsg.Verbose
seal.sys.Verbosef = fmsg.Verbosef
seal.sys.WrapErr = fmsg.WrapError
/* /*
Work directories Work directories
@ -290,27 +259,35 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
// inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as post-fsu user // inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as post-fsu user
innerRuntimeDir := path.Join("/run/user", mapuid.String()) innerRuntimeDir := path.Join("/run/user", mapuid.String())
seal.container.Tmpfs("/run/user", 1<<12, 0755) seal.container.Tmpfs("/run/user", 1*1024*1024)
seal.container.Tmpfs(innerRuntimeDir, 1<<23, 0755) seal.container.Tmpfs(innerRuntimeDir, 8*1024*1024)
seal.env[xdgRuntimeDir] = innerRuntimeDir seal.container.SetEnv[xdgRuntimeDir] = innerRuntimeDir
seal.env[xdgSessionClass] = "user" seal.container.SetEnv[xdgSessionClass] = "user"
seal.env[xdgSessionType] = "tty" seal.container.SetEnv[xdgSessionType] = "tty"
// outer path for inner /tmp // outer path for inner /tmp
{ {
tmpdir := path.Join(sc.SharePath, "tmpdir") tmpdir := path.Join(sc.SharePath, "tmpdir")
seal.sys.Ensure(tmpdir, 0700) seal.sys.Ensure(tmpdir, 0700)
seal.sys.UpdatePermType(system.User, tmpdir, acl.Execute) seal.sys.UpdatePermType(system.User, tmpdir, acl.Execute)
tmpdirInst := path.Join(tmpdir, seal.user.aid.String()) tmpdirProc := path.Join(tmpdir, seal.user.aid.String())
seal.sys.Ensure(tmpdirInst, 01700) seal.sys.Ensure(tmpdirProc, 01700)
seal.sys.UpdatePermType(system.User, tmpdirInst, acl.Read, acl.Write, acl.Execute) seal.sys.UpdatePermType(system.User, tmpdirProc, acl.Read, acl.Write, acl.Execute)
seal.container.Bind(tmpdirInst, "/tmp", sandbox.BindWritable) seal.container.Bind(tmpdirProc, "/tmp", false, true)
} }
/* /*
Passwd database Passwd database
*/ */
// look up shell
sh := "/bin/sh"
if s, ok := sys.LookupEnv(shell); ok {
seal.container.SetEnv[shell] = s
sh = s
}
// bind home directory
homeDir := "/var/empty" homeDir := "/var/empty"
if seal.user.home != "" { if seal.user.home != "" {
homeDir = seal.user.home homeDir = seal.user.home
@ -319,26 +296,28 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
if seal.user.username != "" { if seal.user.username != "" {
username = seal.user.username username = seal.user.username
} }
seal.container.Bind(seal.user.data, homeDir, sandbox.BindWritable) seal.container.Bind(seal.user.data, homeDir, false, true)
seal.container.Dir = homeDir seal.container.Chdir = homeDir
seal.env["HOME"] = homeDir seal.container.SetEnv["HOME"] = homeDir
seal.env["USER"] = username seal.container.SetEnv["USER"] = username
seal.container.Place("/etc/passwd", // generate /etc/passwd and /etc/group
[]byte(username+":x:"+mapuid.String()+":"+mapgid.String()+":Fortify:"+homeDir+":"+shellPath+"\n")) seal.container.CopyBind("/etc/passwd",
seal.container.Place("/etc/group", []byte(username+":x:"+mapuid.String()+":"+mapuid.String()+":Fortify:"+homeDir+":"+sh+"\n"))
[]byte("fortify:x:"+mapgid.String()+":\n")) seal.container.CopyBind("/etc/group",
[]byte("fortify:x:"+mapuid.String()+":\n"))
/* /*
Display servers Display servers
*/ */
// pass $TERM for proper terminal I/O in shell // pass $TERM to launcher
if t, ok := sys.LookupEnv(term); ok { if t, ok := sys.LookupEnv(term); ok {
seal.env[term] = t seal.container.SetEnv[term] = t
} }
if config.Confinement.Enablements&system.EWayland != 0 { // set up wayland
if config.Confinement.Enablements.Has(system.EWayland) {
// outer wayland socket (usually `/run/user/%d/wayland-%d`) // outer wayland socket (usually `/run/user/%d/wayland-%d`)
var socketPath string var socketPath string
if name, ok := sys.LookupEnv(wl.WaylandDisplay); !ok { if name, ok := sys.LookupEnv(wl.WaylandDisplay); !ok {
@ -351,7 +330,7 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
} }
innerPath := path.Join(innerRuntimeDir, wl.FallbackName) innerPath := path.Join(innerRuntimeDir, wl.FallbackName)
seal.env[wl.WaylandDisplay] = wl.FallbackName seal.container.SetEnv[wl.WaylandDisplay] = wl.FallbackName
if !config.Confinement.Sandbox.DirectWayland { // set up security-context-v1 if !config.Confinement.Sandbox.DirectWayland { // set up security-context-v1
socketDir := path.Join(sc.SharePath, "wayland") socketDir := path.Join(sc.SharePath, "wayland")
@ -362,23 +341,25 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
// use instance ID in case app id is not set // use instance ID in case app id is not set
appID = "uk.gensokyo.fortify." + seal.id.String() appID = "uk.gensokyo.fortify." + seal.id.String()
} }
seal.sys.Wayland(&seal.sync, outerPath, socketPath, appID, seal.id.String()) seal.sys.Wayland(&seal.bwrapSync, outerPath, socketPath, appID, seal.id.String())
seal.container.Bind(outerPath, innerPath, 0) seal.container.Bind(outerPath, innerPath)
} else { // bind mount wayland socket (insecure) } else { // bind mount wayland socket (insecure)
fmsg.Verbose("direct wayland access, PROCEED WITH CAUTION") fmsg.Verbose("direct wayland access, PROCEED WITH CAUTION")
seal.container.Bind(socketPath, innerPath, 0) seal.container.Bind(socketPath, innerPath)
seal.sys.UpdatePermType(system.EWayland, socketPath, acl.Read, acl.Write, acl.Execute) seal.sys.UpdatePermType(system.EWayland, socketPath, acl.Read, acl.Write, acl.Execute)
} }
} }
if config.Confinement.Enablements&system.EX11 != 0 { // set up X11
if config.Confinement.Enablements.Has(system.EX11) {
// discover X11 and grant user permission via the `ChangeHosts` command
if d, ok := sys.LookupEnv(display); !ok { if d, ok := sys.LookupEnv(display); !ok {
return fmsg.WrapError(ErrXDisplay, return fmsg.WrapError(ErrXDisplay,
"DISPLAY is not set") "DISPLAY is not set")
} else { } else {
seal.sys.ChangeHosts("#" + seal.user.uid.String()) seal.sys.ChangeHosts("#" + seal.user.uid.String())
seal.env[display] = d seal.container.SetEnv[display] = d
seal.container.Bind("/tmp/.X11-unix", "/tmp/.X11-unix", 0) seal.container.Bind("/tmp/.X11-unix", "/tmp/.X11-unix")
} }
} }
@ -386,7 +367,7 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
PulseAudio server and authentication PulseAudio server and authentication
*/ */
if config.Confinement.Enablements&system.EPulse != 0 { if config.Confinement.Enablements.Has(system.EPulse) {
// PulseAudio runtime directory (usually `/run/user/%d/pulse`) // PulseAudio runtime directory (usually `/run/user/%d/pulse`)
pulseRuntimeDir := path.Join(sc.RuntimePath, "pulse") pulseRuntimeDir := path.Join(sc.RuntimePath, "pulse")
// PulseAudio socket (usually `/run/user/%d/pulse/native`) // PulseAudio socket (usually `/run/user/%d/pulse/native`)
@ -419,8 +400,8 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
innerPulseRuntimeDir := path.Join(sharePathLocal, "pulse") innerPulseRuntimeDir := path.Join(sharePathLocal, "pulse")
innerPulseSocket := path.Join(innerRuntimeDir, "pulse", "native") innerPulseSocket := path.Join(innerRuntimeDir, "pulse", "native")
seal.sys.Link(pulseSocket, innerPulseRuntimeDir) seal.sys.Link(pulseSocket, innerPulseRuntimeDir)
seal.container.Bind(innerPulseRuntimeDir, innerPulseSocket, 0) seal.container.Bind(innerPulseRuntimeDir, innerPulseSocket)
seal.env[pulseServer] = "unix:" + innerPulseSocket seal.container.SetEnv[pulseServer] = "unix:" + innerPulseSocket
// publish current user's pulse cookie for target user // publish current user's pulse cookie for target user
if src, err := discoverPulseCookie(sys); err != nil { if src, err := discoverPulseCookie(sys); err != nil {
@ -428,9 +409,9 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
fmsg.Verbose(strings.TrimSpace(err.(*fmsg.BaseError).Message())) fmsg.Verbose(strings.TrimSpace(err.(*fmsg.BaseError).Message()))
} else { } else {
innerDst := fst.Tmp + "/pulse-cookie" innerDst := fst.Tmp + "/pulse-cookie"
seal.env[pulseCookie] = innerDst seal.container.SetEnv[pulseCookie] = innerDst
var payload *[]byte payload := new([]byte)
seal.container.PlaceP(innerDst, &payload) seal.container.CopyBindRef(innerDst, &payload)
seal.sys.CopyFile(payload, src, 256, 256) seal.sys.CopyFile(payload, src, 256, 256)
} }
} }
@ -439,7 +420,7 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
D-Bus proxy D-Bus proxy
*/ */
if config.Confinement.Enablements&system.EDBus != 0 { if config.Confinement.Enablements.Has(system.EDBus) {
// ensure dbus session bus defaults // ensure dbus session bus defaults
if config.Confinement.SessionBus == nil { if config.Confinement.SessionBus == nil {
config.Confinement.SessionBus = dbus.NewConfig(config.ID, true, true) config.Confinement.SessionBus = dbus.NewConfig(config.ID, true, true)
@ -460,13 +441,13 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
// share proxy sockets // share proxy sockets
sessionInner := path.Join(innerRuntimeDir, "bus") sessionInner := path.Join(innerRuntimeDir, "bus")
seal.env[dbusSessionBusAddress] = "unix:path=" + sessionInner seal.container.SetEnv[dbusSessionBusAddress] = "unix:path=" + sessionInner
seal.container.Bind(sessionPath, sessionInner, 0) seal.container.Bind(sessionPath, sessionInner)
seal.sys.UpdatePerm(sessionPath, acl.Read, acl.Write) seal.sys.UpdatePerm(sessionPath, acl.Read, acl.Write)
if config.Confinement.SystemBus != nil { if config.Confinement.SystemBus != nil {
systemInner := "/run/dbus/system_bus_socket" systemInner := "/run/dbus/system_bus_socket"
seal.env[dbusSystemBusAddress] = "unix:path=" + systemInner seal.container.SetEnv[dbusSystemBusAddress] = "unix:path=" + systemInner
seal.container.Bind(systemPath, systemInner, 0) seal.container.Bind(systemPath, systemInner)
seal.sys.UpdatePerm(systemPath, acl.Read, acl.Write) seal.sys.UpdatePerm(systemPath, acl.Read, acl.Write)
} }
} }
@ -475,8 +456,9 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
Miscellaneous Miscellaneous
*/ */
for _, dest := range config.Confinement.Sandbox.Cover { // queue overriding tmpfs at the end of seal.container.Filesystem
seal.container.Tmpfs(dest, 1<<13, 0755) for _, dest := range config.Confinement.Sandbox.Override {
seal.container.Tmpfs(dest, 8*1024)
} }
// append ExtraPerms last // append ExtraPerms last
@ -502,13 +484,12 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
seal.sys.UpdatePermType(system.User, p.Path, perms...) seal.sys.UpdatePermType(system.User, p.Path, perms...)
} }
// flatten and sort env for deterministic behaviour // mount fortify in sandbox for init
seal.container.Env = make([]string, 0, len(seal.env)) seal.container.Bind(sys.MustExecutable(), path.Join(fst.Tmp, "sbin/fortify"))
maps.All(seal.env)(func(k string, v string) bool { seal.container.Env = append(seal.container.Env, k+"="+v); return true }) seal.container.Symlink("fortify", path.Join(fst.Tmp, "sbin/init"))
slices.Sort(seal.container.Env)
fmsg.Verbosef("created application seal for uid %s (%s) groups: %v, argv: %s", fmsg.Verbosef("created application seal for uid %s (%s) groups: %v, command: %s",
seal.user.uid, seal.user.username, config.Confinement.Groups, seal.container.Args) seal.user.uid, seal.user.username, config.Confinement.Groups, config.Command)
return nil return nil
} }

View File

@ -1,212 +0,0 @@
package app
import (
"context"
"encoding/gob"
"errors"
"log"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/sandbox"
)
const shimEnv = "FORTIFY_SHIM"
type shimParams struct {
// finalised container params
Container *sandbox.Params
// path to outer home directory
Home string
// verbosity pass through
Verbose bool
}
// ShimMain is the main function of the shim process and runs as the unconstrained target user.
func ShimMain() {
fmsg.Prepare("shim")
if err := sandbox.SetDumpable(sandbox.SUID_DUMP_DISABLE); err != nil {
log.Fatalf("cannot set SUID_DUMP_DISABLE: %s", err)
}
var (
params shimParams
closeSetup func() error
)
if f, err := sandbox.Receive(shimEnv, &params, nil); err != nil {
if errors.Is(err, sandbox.ErrInvalid) {
log.Fatal("invalid config descriptor")
}
if errors.Is(err, sandbox.ErrNotSet) {
log.Fatal("FORTIFY_SHIM not set")
}
log.Fatalf("cannot receive shim setup params: %v", err)
} else {
internal.InstallFmsg(params.Verbose)
closeSetup = f
}
if params.Container == nil || params.Container.Ops == nil {
log.Fatal("invalid container params")
}
// close setup socket
if err := closeSetup(); err != nil {
log.Printf("cannot close setup pipe: %v", err)
// not fatal
}
// ensure home directory as target user
if s, err := os.Stat(params.Home); err != nil {
if os.IsNotExist(err) {
if err = os.Mkdir(params.Home, 0700); err != nil {
log.Fatalf("cannot create home directory: %v", err)
}
} else {
log.Fatalf("cannot access home directory: %v", err)
}
// home directory is created, proceed
} else if !s.IsDir() {
log.Fatalf("path %q is not a directory", params.Home)
}
var name string
if len(params.Container.Args) > 0 {
name = params.Container.Args[0]
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() // unreachable
container := sandbox.New(ctx, name)
container.Params = *params.Container
container.Stdin, container.Stdout, container.Stderr = os.Stdin, os.Stdout, os.Stderr
container.Cancel = func(cmd *exec.Cmd) error { return cmd.Process.Signal(os.Interrupt) }
container.WaitDelay = 2 * time.Second
if err := container.Start(); err != nil {
fmsg.PrintBaseError(err, "cannot start container:")
os.Exit(1)
}
if err := container.Serve(); err != nil {
fmsg.PrintBaseError(err, "cannot configure container:")
}
if err := container.Wait(); err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
if errors.Is(err, context.Canceled) {
os.Exit(2)
}
log.Printf("wait: %v", err)
os.Exit(127)
}
os.Exit(exitError.ExitCode())
}
}
type shimProcess struct {
// user switcher process
cmd *exec.Cmd
// fallback exit notifier with error returned killing the process
killFallback chan error
// monitor to shim encoder
encoder *gob.Encoder
}
func (s *shimProcess) Unwrap() *exec.Cmd { return s.cmd }
func (s *shimProcess) Fallback() chan error { return s.killFallback }
func (s *shimProcess) String() string {
if s.cmd == nil {
return "(unused shim manager)"
}
return s.cmd.String()
}
func (s *shimProcess) Start(
aid string,
supp []string,
) (*time.Time, error) {
// prepare user switcher invocation
fsuPath := internal.MustFsuPath()
s.cmd = exec.Command(fsuPath)
// pass shim setup pipe
if fd, e, err := sandbox.Setup(&s.cmd.ExtraFiles); err != nil {
return nil, fmsg.WrapErrorSuffix(err,
"cannot create shim setup pipe:")
} else {
s.encoder = e
s.cmd.Env = []string{
shimEnv + "=" + strconv.Itoa(fd),
"FORTIFY_APP_ID=" + aid,
}
}
// format fsu supplementary groups
if len(supp) > 0 {
fmsg.Verbosef("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 = "/"
fmsg.Verbose("starting shim via fsu:", s.cmd)
// withhold messages to stderr
fmsg.Suspend()
if err := s.cmd.Start(); err != nil {
return nil, fmsg.WrapErrorSuffix(err,
"cannot start fsu:")
}
startTime := time.Now().UTC()
return &startTime, nil
}
func (s *shimProcess) Serve(ctx context.Context, params *shimParams) error {
// kill shim if something goes wrong and an error is returned
s.killFallback = make(chan error, 1)
killShim := func() {
if err := s.cmd.Process.Signal(os.Interrupt); err != nil {
s.killFallback <- err
}
}
defer func() { killShim() }()
encodeErr := make(chan error)
go func() { encodeErr <- s.encoder.Encode(params) }()
select {
// encode return indicates setup completion
case err := <-encodeErr:
if err != nil {
return fmsg.WrapErrorSuffix(err,
"cannot transmit shim config:")
}
killShim = func() {}
return nil
// setup canceled before payload was accepted
case <-ctx.Done():
err := ctx.Err()
if errors.Is(err, context.Canceled) {
return fmsg.WrapError(syscall.ECANCELED,
"shim setup canceled")
}
if errors.Is(err, context.DeadlineExceeded) {
return fmsg.WrapError(syscall.ETIMEDOUT,
"deadline exceeded waiting for shim")
}
// unreachable
return err
}
}

153
internal/app/shim/main.go Normal file
View File

@ -0,0 +1,153 @@
package shim
import (
"context"
"errors"
"log"
"os"
"os/exec"
"os/signal"
"path"
"strconv"
"syscall"
"git.gensokyo.uk/security/fortify/fst"
"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/internal"
init0 "git.gensokyo.uk/security/fortify/internal/app/init"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
// everything beyond this point runs as unconstrained target user
// proceed with caution!
func Main() {
// sharing stdout with fortify
// USE WITH CAUTION
fmsg.Prepare("shim")
// setting this prevents ptrace
if err := internal.PR_SET_DUMPABLE__SUID_DUMP_DISABLE(); err != nil {
log.Fatalf("cannot set SUID_DUMP_DISABLE: %s", err)
}
// receive setup payload
var (
payload Payload
closeSetup func() error
)
if f, err := proc.Receive(Env, &payload); err != nil {
if errors.Is(err, proc.ErrInvalid) {
log.Fatal("invalid config descriptor")
}
if errors.Is(err, proc.ErrNotSet) {
log.Fatal("FORTIFY_SHIM not set")
}
log.Fatalf("cannot decode shim setup payload: %v", err)
} else {
fmsg.Store(payload.Verbose)
closeSetup = f
}
if payload.Bwrap == nil {
log.Fatal("bwrap config not supplied")
}
// restore bwrap sync fd
var syncFd *os.File
if payload.Sync != nil {
syncFd = os.NewFile(*payload.Sync, "sync")
}
// close setup socket
if err := closeSetup(); err != nil {
log.Println("cannot close setup pipe:", err)
// not fatal
}
// ensure home directory as target user
if s, err := os.Stat(payload.Home); err != nil {
if os.IsNotExist(err) {
if err = os.Mkdir(payload.Home, 0700); err != nil {
log.Fatalf("cannot create home directory: %v", err)
}
} else {
log.Fatalf("cannot access home directory: %v", err)
}
// home directory is created, proceed
} else if !s.IsDir() {
log.Fatalf("data path %q is not a directory", payload.Home)
}
var ic init0.Payload
// resolve argv0
ic.Argv = payload.Argv
if len(ic.Argv) > 0 {
// looked up from $PATH by parent
ic.Argv0 = payload.Exec[1]
} else {
// no argv, look up shell instead
var ok bool
if payload.Bwrap.SetEnv == nil {
log.Fatal("no command was specified and environment is unset")
}
if ic.Argv0, ok = payload.Bwrap.SetEnv["SHELL"]; !ok {
log.Fatal("no command was specified and $SHELL was unset")
}
ic.Argv = []string{ic.Argv0}
}
conf := payload.Bwrap
var extraFiles []*os.File
// serve setup payload
if fd, encoder, err := proc.Setup(&extraFiles); err != nil {
log.Fatalf("cannot pipe: %v", err)
} else {
conf.SetEnv[init0.Env] = strconv.Itoa(fd)
go func() {
fmsg.Verbose("transmitting config to init")
if err = encoder.Encode(&ic); err != nil {
log.Fatalf("cannot transmit init config: %v", err)
}
}()
}
helper.BubblewrapName = payload.Exec[0] // resolved bwrap path by parent
if fmsg.Load() {
seccomp.CPrintln = log.Println
}
if b, err := helper.NewBwrap(
conf, path.Join(fst.Tmp, "sbin/init"),
nil, func(int, int) []string { return make([]string, 0) },
extraFiles,
syncFd,
); err != nil {
log.Fatalf("malformed sandbox config: %v", err)
} else {
b.Stdin(os.Stdin).Stdout(os.Stdout).Stderr(os.Stderr)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() // unreachable
// run and pass through exit code
if err = b.Start(ctx, false); err != nil {
log.Fatalf("cannot start target process: %v", err)
} else if err = b.Wait(); err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
log.Printf("wait: %v", err)
internal.Exit(127)
panic("unreachable")
}
internal.Exit(exitError.ExitCode())
panic("unreachable")
}
}
}

View File

@ -0,0 +1,139 @@
package shim
import (
"context"
"encoding/gob"
"errors"
"os"
"os/exec"
"strconv"
"strings"
"time"
"git.gensokyo.uk/security/fortify/helper/proc"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
// used by the parent process
type Shim struct {
// user switcher process
cmd *exec.Cmd
// fallback exit notifier with error returned killing the process
killFallback chan error
// monitor to shim encoder
encoder *gob.Encoder
// bwrap --sync-fd value
sync *uintptr
}
func (s *Shim) String() string {
if s.cmd == nil {
return "(unused shim manager)"
}
return s.cmd.String()
}
func (s *Shim) Unwrap() *exec.Cmd {
return s.cmd
}
func (s *Shim) WaitFallback() chan error {
return s.killFallback
}
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 {
return nil, fmsg.WrapError(errors.New("bad fsu path"),
"invalid fsu path, this copy of fortify is not compiled correctly")
} else {
fsu = p
}
s.cmd = exec.Command(fsu)
// pass shim setup pipe
if fd, e, err := proc.Setup(&s.cmd.ExtraFiles); err != nil {
return nil, fmsg.WrapErrorSuffix(err,
"cannot create shim setup pipe:")
} else {
s.encoder = e
s.cmd.Env = []string{
Env + "=" + strconv.Itoa(fd),
"FORTIFY_APP_ID=" + aid,
}
}
// format fsu supplementary groups
if len(supp) > 0 {
fmsg.Verbosef("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 syncFd != nil {
fd := proc.ExtraFile(s.cmd, syncFd)
s.sync = &fd
}
fmsg.Verbose("starting shim via fsu:", s.cmd)
// withhold messages to stderr
fmsg.Suspend()
if err := s.cmd.Start(); err != nil {
return nil, fmsg.WrapErrorSuffix(err,
"cannot start fsu:")
}
startTime := time.Now().UTC()
return &startTime, nil
}
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() {
if err := s.cmd.Process.Signal(os.Interrupt); err != nil {
s.killFallback <- err
}
}
defer func() { killShim() }()
payload.Sync = s.sync
encodeErr := make(chan error)
go func() { encodeErr <- s.encoder.Encode(payload) }()
select {
// encode return indicates setup completion
case err := <-encodeErr:
if err != nil {
return fmsg.WrapErrorSuffix(err,
"cannot transmit shim config:")
}
killShim = func() {}
return nil
// setup canceled before payload was accepted
case <-ctx.Done():
err := ctx.Err()
if errors.Is(err, context.Canceled) {
return fmsg.WrapError(errors.New("shim setup canceled"),
"shim setup canceled")
}
if errors.Is(err, context.DeadlineExceeded) {
return fmsg.WrapError(errors.New("deadline exceeded waiting for shim"),
"deadline exceeded waiting for shim")
}
// unreachable
return err
}
}

View File

@ -0,0 +1,23 @@
package shim
import (
"git.gensokyo.uk/security/fortify/helper/bwrap"
)
const Env = "FORTIFY_SHIM"
type Payload struct {
// child full argv
Argv []string
// bwrap, target full exec path
Exec [2]string
// bwrap config
Bwrap *bwrap.Config
// path to outer home directory
Home string
// sync fd
Sync *uintptr
// verbosity pass through
Verbose bool
}

View File

@ -3,15 +3,10 @@ package internal
const compPoison = "INVALIDINVALIDINVALIDINVALIDINVALID" const compPoison = "INVALIDINVALIDINVALIDINVALIDINVALID"
var ( var (
version = compPoison Version = compPoison
) )
// check validates string value set at compile time. // Check validates string value set at compile time.
func check(s string) (string, bool) { return s, s != compPoison && s != "" } func Check(s string) (string, bool) {
return s, s != compPoison && s != ""
func Version() string {
if v, ok := check(version); ok {
return v
}
return "impure"
} }

View File

@ -1,9 +1,11 @@
package sandbox package internal
import ( import (
"log" "log"
"os" "os"
"sync" "sync"
"git.gensokyo.uk/security/fortify/internal/fmsg"
) )
var ( var (
@ -13,7 +15,7 @@ var (
func copyExecutable() { func copyExecutable() {
if name, err := os.Executable(); err != nil { if name, err := os.Executable(); err != nil {
msg.BeforeExit() fmsg.BeforeExit()
log.Fatalf("cannot read executable path: %v", err) log.Fatalf("cannot read executable path: %v", err)
} else { } else {
executable = name executable = name

View File

@ -1,12 +0,0 @@
package fmsg
type Output struct{}
func (Output) IsVerbose() bool { return Load() }
func (Output) Verbose(v ...any) { Verbose(v...) }
func (Output) Verbosef(format string, v ...any) { Verbosef(format, v...) }
func (Output) WrapErr(err error, a ...any) error { return WrapError(err, a...) }
func (Output) PrintBaseErr(err error, fallback string) { PrintBaseError(err, fallback) }
func (Output) Suspend() { Suspend() }
func (Output) Resume() bool { return Resume() }
func (Output) BeforeExit() { BeforeExit() }

View File

@ -1,17 +0,0 @@
package internal
import (
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
"git.gensokyo.uk/security/fortify/system"
)
func InstallFmsg(verbose bool) {
fmsg.Store(verbose)
sandbox.SetOutput(fmsg.Output{})
system.SetOutput(fmsg.Output{})
if verbose {
seccomp.SetOutput(fmsg.Verbose)
}
}

View File

@ -1,23 +1,11 @@
package internal package internal
import ( import "path"
"log"
"path"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
var ( var (
fsu = compPoison Fsu = compPoison
) )
func MustFsuPath() string { func Path(p string) (string, bool) {
if name, ok := checkPath(fsu); ok { return p, p != compPoison && p != "" && path.IsAbs(p)
return name
}
fmsg.BeforeExit()
log.Fatal("invalid fsu path, this program is compiled incorrectly")
return compPoison
} }
func checkPath(p string) (string, bool) { return p, p != compPoison && p != "" && path.IsAbs(p) }

20
internal/prctl.go Normal file
View File

@ -0,0 +1,20 @@
package internal
import "syscall"
func PR_SET_DUMPABLE__SUID_DUMP_DISABLE() error {
// linux/sched/coredump.h
if _, _, errno := syscall.RawSyscall(syscall.SYS_PRCTL, syscall.PR_SET_DUMPABLE, 0, 0); errno != 0 {
return errno
}
return nil
}
func PR_SET_PDEATHSIG__SIGKILL() error {
if _, _, errno := syscall.RawSyscall(syscall.SYS_PRCTL, syscall.PR_SET_PDEATHSIG, uintptr(syscall.SIGKILL), 0); errno != 0 {
return errno
}
return nil
}

View File

@ -96,7 +96,7 @@ func testStore(t *testing.T, s state.Store) {
} else { } else {
slices.Sort(aids) slices.Sort(aids)
want := []int{0, 1} want := []int{0, 1}
if !slices.Equal(aids, want) { if slices.Compare(aids, want) != 0 {
t.Fatalf("List() = %#v, want %#v", aids, want) t.Fatalf("List() = %#v, want %#v", aids, want)
} }
} }

View File

@ -12,10 +12,8 @@ import (
// State provides safe interaction with operating system state. // State provides safe interaction with operating system state.
type State interface { type State interface {
// Getuid provides [os.Getuid]. // Geteuid provides [os.Geteuid].
Getuid() int Geteuid() int
// Getgid provides [os.Getgid].
Getgid() int
// LookupEnv provides [os.LookupEnv]. // LookupEnv provides [os.LookupEnv].
LookupEnv(key string) (string, bool) LookupEnv(key string) (string, bool)
// TempDir provides [os.TempDir]. // TempDir provides [os.TempDir].
@ -49,7 +47,7 @@ type State interface {
// CopyPaths is a generic implementation of [System.Paths]. // CopyPaths is a generic implementation of [System.Paths].
func CopyPaths(os State, v *fst.Paths) { func CopyPaths(os State, v *fst.Paths) {
v.SharePath = path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Getuid())) v.SharePath = path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Geteuid()))
fmsg.Verbosef("process share directory at %q", v.SharePath) fmsg.Verbosef("process share directory at %q", v.SharePath)

View File

@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"log"
"os" "os"
"os/exec" "os/exec"
"os/user" "os/user"
@ -15,7 +16,6 @@ import (
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"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/sandbox"
) )
// Std implements System using the standard library. // Std implements System using the standard library.
@ -31,12 +31,11 @@ type Std struct {
uidMu sync.RWMutex uidMu sync.RWMutex
} }
func (s *Std) Getuid() int { return os.Getuid() } func (s *Std) Geteuid() int { return os.Geteuid() }
func (s *Std) Getgid() int { return os.Getgid() }
func (s *Std) LookupEnv(key string) (string, bool) { return os.LookupEnv(key) } func (s *Std) LookupEnv(key string) (string, bool) { return os.LookupEnv(key) }
func (s *Std) TempDir() string { return os.TempDir() } func (s *Std) TempDir() string { return os.TempDir() }
func (s *Std) LookPath(file string) (string, error) { return exec.LookPath(file) } func (s *Std) LookPath(file string) (string, error) { return exec.LookPath(file) }
func (s *Std) MustExecutable() string { return sandbox.MustExecutable() } func (s *Std) MustExecutable() string { return internal.MustExecutable() }
func (s *Std) LookupGroup(name string) (*user.Group, error) { return user.LookupGroup(name) } func (s *Std) LookupGroup(name string) (*user.Group, error) { return user.LookupGroup(name) }
func (s *Std) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) } func (s *Std) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
func (s *Std) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) } func (s *Std) Stat(name string) (fs.FileInfo, error) { return os.Stat(name) }
@ -80,10 +79,14 @@ func (s *Std) Uid(aid int) (int, error) {
defer func() { s.uidCopy[aid] = u }() defer func() { s.uidCopy[aid] = u }()
u.uid = -1 u.uid = -1
fsuPath := internal.MustFsuPath() if fsu, ok := internal.Check(internal.Fsu); !ok {
fmsg.BeforeExit()
cmd := exec.Command(fsuPath) log.Fatal("invalid fsu path, this copy of fortify is not compiled correctly")
cmd.Path = fsuPath // unreachable
return 0, syscall.EBADE
} else {
cmd := exec.Command(fsu)
cmd.Path = fsu
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 = "/"
@ -100,7 +103,8 @@ func (s *Std) Uid(aid int) (int, error) {
} else if errors.As(u.err, &exitError) && exitError != nil && exitError.ExitCode() == 1 { } else if errors.As(u.err, &exitError) && exitError != nil && exitError.ExitCode() == 1 {
u.err = fmsg.WrapError(syscall.EACCES, "") // fsu prints to stderr in this case u.err = fmsg.WrapError(syscall.EACCES, "") // fsu prints to stderr in this case
} else if os.IsNotExist(u.err) { } else if os.IsNotExist(u.err) {
u.err = fmsg.WrapError(os.ErrNotExist, fmt.Sprintf("the setuid helper is missing: %s", fsuPath)) u.err = fmsg.WrapError(os.ErrNotExist, fmt.Sprintf("the setuid helper is missing: %s", fsu))
} }
return u.uid, u.err return u.uid, u.err
}
} }

View File

@ -3,56 +3,56 @@ package ldd
import ( import (
"bytes" "bytes"
"context" "context"
"io"
"os" "os"
"os/exec" "os/exec"
"time" "time"
"git.gensokyo.uk/security/fortify/sandbox" "git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/helper/bwrap"
) )
const lddTimeout = 2 * time.Second const lddTimeout = 2 * time.Second
var ( var (
msgStatic = []byte("Not a valid dynamic program")
msgStaticGlibc = []byte("not a dynamic executable") msgStaticGlibc = []byte("not a dynamic executable")
) )
func Exec(ctx context.Context, p string) ([]*Entry, error) { return ExecFilter(ctx, nil, nil, p) } func Exec(ctx context.Context, p string) ([]*Entry, error) {
var h helper.Helper
if toolPath, err := exec.LookPath("ldd"); err != nil {
return nil, err
} else if h, err = helper.NewBwrap(
(&bwrap.Config{
Hostname: "fortify-ldd",
Chdir: "/",
Syscall: &bwrap.SyscallPolicy{DenyDevel: true, Multiarch: true},
NewSession: true,
DieWithParent: true,
}).Bind("/", "/").DevTmpfs("/dev"), toolPath,
nil, func(_, _ int) []string { return []string{p} },
nil, nil,
); err != nil {
return nil, err
}
stdout, stderr := new(bytes.Buffer), new(bytes.Buffer)
h.Stdout(stdout).Stderr(stderr)
func ExecFilter(ctx context.Context,
commandContext func(context.Context) *exec.Cmd,
f func([]byte) []byte,
p string) ([]*Entry, error) {
c, cancel := context.WithTimeout(ctx, lddTimeout) c, cancel := context.WithTimeout(ctx, lddTimeout)
defer cancel() defer cancel()
container := sandbox.New(c, "ldd", p) if err := h.Start(c, false); err != nil {
container.CommandContext = commandContext
container.Hostname = "fortify-ldd"
stdout, stderr := new(bytes.Buffer), new(bytes.Buffer)
container.Stdout = stdout
container.Stderr = stderr
container.Bind("/", "/", 0).Proc("/proc").Dev("/dev")
if err := container.Start(); err != nil {
return nil, err return nil, err
} }
defer func() { _, _ = io.Copy(os.Stderr, stderr) }() if err := h.Wait(); err != nil {
if err := container.Serve(); err != nil {
return nil, err
}
if err := container.Wait(); err != nil {
m := stderr.Bytes() m := stderr.Bytes()
if bytes.Contains(m, append([]byte(p+": "), msgStatic...)) || if bytes.Contains(m, msgStaticGlibc) {
bytes.Contains(m, msgStaticGlibc) {
return nil, nil return nil, nil
} }
_, _ = os.Stderr.Write(m)
return nil, err return nil, err
} }
v := stdout.Bytes() return Parse(stdout)
if f != nil {
v = f(v)
}
return Parse(v)
} }

View File

@ -2,6 +2,7 @@
package ldd package ldd
import ( import (
"fmt"
"math" "math"
"path" "path"
"strconv" "strconv"
@ -14,8 +15,8 @@ type Entry struct {
Location uint64 `json:"location"` Location uint64 `json:"location"`
} }
func Parse(p []byte) ([]*Entry, error) { func Parse(stdout fmt.Stringer) ([]*Entry, error) {
payload := strings.Split(strings.TrimSpace(string(p)), "\n") payload := strings.Split(strings.TrimSpace(stdout.String()), "\n")
result := make([]*Entry, len(payload)) result := make([]*Entry, len(payload))
for i, ent := range payload { for i, ent := range payload {

View File

@ -3,6 +3,7 @@ package ldd_test
import ( import (
"errors" "errors"
"reflect" "reflect"
"strings"
"testing" "testing"
"git.gensokyo.uk/security/fortify/ldd" "git.gensokyo.uk/security/fortify/ldd"
@ -33,7 +34,10 @@ libzstd.so.1 => /usr/lib/libzstd.so.1 7ff71bfd2000
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
if _, err := ldd.Parse([]byte(tc.out)); !errors.Is(err, tc.wantErr) { stdout := new(strings.Builder)
stdout.WriteString(tc.out)
if _, err := ldd.Parse(stdout); !errors.Is(err, tc.wantErr) {
t.Errorf("Parse() error = %v, wantErr %v", err, tc.wantErr) t.Errorf("Parse() error = %v, wantErr %v", err, tc.wantErr)
} }
}) })
@ -107,7 +111,10 @@ libc.musl-x86_64.so.1 => /lib/ld-musl-x86_64.so.1 (0x7ff71c0a4000)`,
} }
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.file, func(t *testing.T) { t.Run(tc.file, func(t *testing.T) {
if got, err := ldd.Parse([]byte(tc.out)); err != nil { stdout := new(strings.Builder)
stdout.WriteString(tc.out)
if got, err := ldd.Parse(stdout); err != nil {
t.Errorf("Parse() error = %v", err) t.Errorf("Parse() error = %v", err)
} else if !reflect.DeepEqual(got, tc.want) { } else if !reflect.DeepEqual(got, tc.want) {
t.Errorf("Parse() got = %#v, want %#v", got, tc.want) t.Errorf("Parse() got = %#v, want %#v", got, tc.want)

View File

@ -1,21 +0,0 @@
package ldd
import (
"path"
"slices"
)
// Path returns a deterministic, deduplicated slice of absolute directory paths in entries.
func Path(entries []*Entry) []string {
p := make([]string, 0, len(entries)*2)
for _, entry := range entries {
if path.IsAbs(entry.Path) {
p = append(p, path.Dir(entry.Path))
}
if path.IsAbs(entry.Name) {
p = append(p, path.Dir(entry.Name))
}
}
slices.Sort(p)
return slices.Compact(p)
}

94
main.go
View File

@ -18,12 +18,14 @@ import (
"git.gensokyo.uk/security/fortify/command" "git.gensokyo.uk/security/fortify/command"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"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/app" "git.gensokyo.uk/security/fortify/internal/app"
init0 "git.gensokyo.uk/security/fortify/internal/app/init"
"git.gensokyo.uk/security/fortify/internal/app/shim"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/state" "git.gensokyo.uk/security/fortify/internal/state"
"git.gensokyo.uk/security/fortify/internal/sys" "git.gensokyo.uk/security/fortify/internal/sys"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/system" "git.gensokyo.uk/security/fortify/system"
) )
@ -39,10 +41,10 @@ func init() { fmsg.Prepare("fortify") }
var std sys.State = new(sys.Std) var std sys.State = new(sys.Std)
func main() { func main() {
// early init path, skips root check and duplicate PR_SET_DUMPABLE // early init argv0 check, skips root check and duplicate PR_SET_DUMPABLE
sandbox.TryArgv0(fmsg.Output{}, fmsg.Prepare, internal.InstallFmsg) init0.TryArgv0()
if err := sandbox.SetDumpable(sandbox.SUID_DUMP_DISABLE); err != nil { if err := internal.PR_SET_DUMPABLE__SUID_DUMP_DISABLE(); err != nil {
log.Printf("cannot set SUID_DUMP_DISABLE: %s", err) log.Printf("cannot set SUID_DUMP_DISABLE: %s", err)
// not fatal: this program runs as the privileged user // not fatal: this program runs as the privileged user
} }
@ -66,15 +68,10 @@ func buildCommand(out io.Writer) command.Command {
flagVerbose bool flagVerbose bool
flagJSON bool flagJSON bool
) )
c := command.New(out, log.Printf, "fortify", func([]string) error { c := command.New(out, log.Printf, "fortify", func([]string) error { fmsg.Store(flagVerbose); return nil }).
internal.InstallFmsg(flagVerbose)
return nil
}).
Flag(&flagVerbose, "v", command.BoolFlag(false), "Print debug messages to the console"). Flag(&flagVerbose, "v", command.BoolFlag(false), "Print debug messages to the console").
Flag(&flagJSON, "json", command.BoolFlag(false), "Serialise output as JSON when applicable") Flag(&flagJSON, "json", command.BoolFlag(false), "Serialise output as JSON when applicable")
c.Command("shim", command.UsageInternal, func([]string) error { app.ShimMain(); return errSuccess })
c.Command("app", "Launch app defined by the specified config file", func(args []string) error { c.Command("app", "Launch app defined by the specified config file", func(args []string) error {
if len(args) < 1 { if len(args) < 1 {
log.Fatal("app requires at least 1 argument") log.Fatal("app requires at least 1 argument")
@ -82,9 +79,10 @@ func buildCommand(out io.Writer) command.Command {
// config extraArgs... // config extraArgs...
config := tryPath(args[0]) config := tryPath(args[0])
config.Args = append(config.Args, args[1:]...) config.Command = append(config.Command, args[1:]...)
runApp(config) // invoke app
runApp(app.MustNew(std), config)
panic("unreachable") panic("unreachable")
}) })
@ -100,15 +98,14 @@ func buildCommand(out io.Writer) command.Command {
groups command.RepeatableFlag groups command.RepeatableFlag
homeDir string homeDir string
userName string userName string
enablements [system.ELen]bool
wayland, x11, dBus, pulse bool
) )
c.NewCommand("run", "Configure and start a permissive default sandbox", func(args []string) error { c.NewCommand("run", "Configure and start a permissive default sandbox", func(args []string) error {
// initialise config from flags // initialise config from flags
config := &fst.Config{ config := &fst.Config{
ID: fid, ID: fid,
Args: args, Command: args,
} }
if aid < 0 || aid > 9999 { if aid < 0 || aid > 9999 {
@ -158,21 +155,15 @@ func buildCommand(out io.Writer) command.Command {
config.Confinement.Outer = homeDir config.Confinement.Outer = homeDir
config.Confinement.Username = userName config.Confinement.Username = userName
if wayland { // enablements from flags
config.Confinement.Enablements |= system.EWayland for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if enablements[i] {
config.Confinement.Enablements.Set(i)
} }
if x11 {
config.Confinement.Enablements |= system.EX11
}
if dBus {
config.Confinement.Enablements |= system.EDBus
}
if pulse {
config.Confinement.Enablements |= system.EPulse
} }
// parse D-Bus config file from flags if applicable // parse D-Bus config file from flags if applicable
if dBus { if enablements[system.EDBus] {
if dbusConfigSession == "builtin" { if dbusConfigSession == "builtin" {
config.Confinement.SessionBus = dbus.NewConfig(fid, true, mpris) config.Confinement.SessionBus = dbus.NewConfig(fid, true, mpris)
} else { } else {
@ -200,7 +191,7 @@ func buildCommand(out io.Writer) command.Command {
} }
// invoke app // invoke app
runApp(config) runApp(app.MustNew(std), config)
panic("unreachable") panic("unreachable")
}). }).
Flag(&dbusConfigSession, "dbus-config", command.StringFlag("builtin"), Flag(&dbusConfigSession, "dbus-config", command.StringFlag("builtin"),
@ -221,13 +212,13 @@ func buildCommand(out io.Writer) command.Command {
"Application home directory"). "Application home directory").
Flag(&userName, "u", command.StringFlag("chronos"), Flag(&userName, "u", command.StringFlag("chronos"),
"Passwd name within sandbox"). "Passwd name within sandbox").
Flag(&wayland, "wayland", command.BoolFlag(false), Flag(&enablements[system.EWayland], "wayland", command.BoolFlag(false),
"Allow Wayland connections"). "Allow Wayland connections").
Flag(&x11, "X", command.BoolFlag(false), Flag(&enablements[system.EX11], "X", command.BoolFlag(false),
"Share X11 socket and allow connection"). "Share X11 socket and allow connection").
Flag(&dBus, "dbus", command.BoolFlag(false), Flag(&enablements[system.EDBus], "dbus", command.BoolFlag(false),
"Proxy D-Bus connection"). "Proxy D-Bus connection").
Flag(&pulse, "pulse", command.BoolFlag(false), Flag(&enablements[system.EPulse], "pulse", command.BoolFlag(false),
"Share PulseAudio socket and cookie") "Share PulseAudio socket and cookie")
} }
@ -258,7 +249,11 @@ func buildCommand(out io.Writer) command.Command {
}).Flag(&psFlagShort, "short", command.BoolFlag(false), "Print instance id") }).Flag(&psFlagShort, "short", command.BoolFlag(false), "Print instance id")
c.Command("version", "Show fortify version", func(args []string) error { c.Command("version", "Show fortify version", func(args []string) error {
fmt.Println(internal.Version()) if v, ok := internal.Check(internal.Version); ok {
fmt.Println(v)
} else {
fmt.Println("impure")
}
return errSuccess return errSuccess
}) })
@ -277,22 +272,45 @@ func buildCommand(out io.Writer) command.Command {
return errSuccess return errSuccess
}) })
// internal commands
c.Command("shim", command.UsageInternal, func([]string) error { shim.Main(); return errSuccess })
c.Command("init", command.UsageInternal, func([]string) error { init0.Main(); return errSuccess })
return c return c
} }
func runApp(config *fst.Config) { func runApp(a fst.App, config *fst.Config) {
rs := new(fst.RunState)
ctx, stop := signal.NotifyContext(context.Background(), ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM) syscall.SIGINT, syscall.SIGTERM)
defer stop() // unreachable defer stop() // unreachable
a := app.MustNew(ctx, std)
rs := new(fst.RunState) if fmsg.Load() {
seccomp.CPrintln = log.Println
}
if sa, err := a.Seal(config); err != nil { if sa, err := a.Seal(config); err != nil {
fmsg.PrintBaseError(err, "cannot seal app:") fmsg.PrintBaseError(err, "cannot seal app:")
rs.ExitCode = 1 internal.Exit(1)
} else if err = sa.Run(ctx, rs); err != nil {
if rs.Time == nil {
fmsg.PrintBaseError(err, "cannot start app:")
} else { } else {
// this updates ExitCode logWaitError(err)
app.PrintRunStateErr(rs, sa.Run(rs)) }
if rs.ExitCode == 0 {
rs.ExitCode = 126
}
}
if rs.RevertErr != nil {
fmsg.PrintBaseError(rs.RevertErr, "generic error returned during cleanup:")
if rs.ExitCode == 0 {
rs.ExitCode = 128
}
}
if rs.WaitErr != nil {
log.Println("inner wait failed:", rs.WaitErr)
} }
internal.Exit(rs.ExitCode) internal.Exit(rs.ExitCode)
} }

View File

@ -1,4 +1,3 @@
packages:
{ {
lib, lib,
pkgs, pkgs,
@ -27,7 +26,7 @@ let
in in
{ {
imports = [ (import ./options.nix packages) ]; imports = [ ./options.nix ];
config = mkIf cfg.enable { config = mkIf cfg.enable {
security.wrappers.fsu = { security.wrappers.fsu = {
@ -78,22 +77,29 @@ in
}; };
in in
{ {
session_bus = if app.dbus.session != null then (app.dbus.session (extendDBusDefault app.id)) else (extendDBusDefault app.id default); session_bus =
if app.dbus.session != null then
(app.dbus.session (extendDBusDefault app.id))
else
(extendDBusDefault app.id default);
system_bus = app.dbus.system; system_bus = app.dbus.system;
}; };
command = if app.command == null then app.name else app.command; command = if app.command == null then app.name else app.command;
script = if app.script == null then ("exec " + command + " $@") else app.script; script = if app.script == null then ("exec " + command + " $@") else app.script;
enablements = with app.capability; (if wayland then 1 else 0) + (if x11 then 2 else 0) + (if dbus then 4 else 0) + (if pulse then 8 else 0); enablements =
isGraphical = if app.gpu != null then app.gpu else app.capability.wayland || app.capability.x11; with app.capability;
(if wayland then 1 else 0)
+ (if x11 then 2 else 0)
+ (if dbus then 4 else 0)
+ (if pulse then 8 else 0);
conf = { conf = {
inherit (app) id; inherit (app) id;
path = pkgs.writeScript "${app.name}-start" '' command = [
(pkgs.writeScript "${app.name}-start" ''
#!${pkgs.zsh}${pkgs.zsh.shellPath} #!${pkgs.zsh}${pkgs.zsh.shellPath}
${script} ${script}
''; '')
args = [ "${app.name}-start" ]; ];
confinement = { confinement = {
app_id = aid; app_id = aid;
inherit (app) groups; inherit (app) groups;
@ -101,17 +107,18 @@ in
home = getsubhome fid aid; home = getsubhome fid aid;
sandbox = { sandbox = {
inherit (app) inherit (app)
devel
userns userns
net net
dev dev
tty
multiarch
env env
; ;
syscall = {
inherit (app) compat multiarch bluetooth;
deny_devel = !app.devel;
};
map_real_uid = app.mapRealUid; map_real_uid = app.mapRealUid;
no_new_session = app.tty;
direct_wayland = app.insecureWayland; direct_wayland = app.insecureWayland;
filesystem = filesystem =
let let
bind = src: { inherit src; }; bind = src: { inherit src; };
@ -128,6 +135,7 @@ in
(mustBind "/bin") (mustBind "/bin")
(mustBind "/usr/bin") (mustBind "/usr/bin")
(mustBind "/nix/store") (mustBind "/nix/store")
(mustBind "/run/current-system")
(bind "/sys/block") (bind "/sys/block")
(bind "/sys/bus") (bind "/sys/bus")
(bind "/sys/class") (bind "/sys/class")
@ -138,7 +146,8 @@ in
(mustBind "/nix/var") (mustBind "/nix/var")
(bind "/var/db/nix-channels") (bind "/var/db/nix-channels")
] ]
++ optionals isGraphical [ ++ optionals (if app.gpu != null then app.gpu else app.capability.wayland || app.capability.x11) [
(bind "/run/opengl-driver")
(devBind "/dev/dri") (devBind "/dev/dri")
(devBind "/dev/nvidiactl") (devBind "/dev/nvidiactl")
(devBind "/dev/nvidia-modeset") (devBind "/dev/nvidia-modeset")
@ -148,38 +157,17 @@ in
] ]
++ app.extraPaths; ++ app.extraPaths;
auto_etc = true; auto_etc = true;
cover = [ "/var/run/nscd" ]; override = [ "/var/run/nscd" ];
symlink =
[
[
"*/run/current-system"
"/run/current-system"
]
]
++ optionals (isGraphical && config.hardware.graphics.enable) (
[
[
config.systemd.tmpfiles.settings.graphics-driver."/run/opengl-driver"."L+".argument
"/run/opengl-driver"
]
]
++ optionals (app.multiarch && config.hardware.graphics.enable32Bit) [
[
config.systemd.tmpfiles.settings.graphics-driver."/run/opengl-driver-32"."L+".argument
/run/opengl-driver-32
]
]
);
}; };
inherit enablements; inherit enablements;
inherit (dbusConfig) session_bus system_bus; inherit (dbusConfig) session_bus system_bus;
}; };
}; };
in in
pkgs.writeShellScriptBin app.name '' pkgs.writeShellScriptBin app.name ''
exec fortify${if app.verbose then " -v" else ""} 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; ) cfg.apps;
in in

View File

@ -1,8 +1,10 @@
packages:
{ lib, pkgs, ... }: { lib, pkgs, ... }:
let let
inherit (lib) types mkOption mkEnableOption; inherit (lib) types mkOption mkEnableOption;
fortify = pkgs.pkgsStatic.callPackage ./package.nix {
inherit (pkgs) bubblewrap xdg-dbus-proxy glibc;
};
in in
{ {
@ -12,13 +14,13 @@ in
package = mkOption { package = mkOption {
type = types.package; type = types.package;
default = packages.${pkgs.system}.fortify; default = fortify;
description = "The fortify package to use."; description = "The fortify package to use.";
}; };
fsuPackage = mkOption { fsuPackage = mkOption {
type = types.package; type = types.package;
default = packages.${pkgs.system}.fsu; default = pkgs.callPackage ./cmd/fsu/package.nix { inherit fortify; };
description = "The fsu package to use."; description = "The fsu package to use.";
}; };
@ -148,19 +150,21 @@ in
''; '';
}; };
devel = mkEnableOption "debugging-related kernel interfaces"; nix = mkEnableOption "nix daemon";
userns = mkEnableOption "user namespace creation"; userns = mkEnableOption "user namespace";
mapRealUid = mkEnableOption "mapping to priv-user uid";
dev = mkEnableOption "access to all devices";
tty = mkEnableOption "access to the controlling terminal"; tty = mkEnableOption "access to the controlling terminal";
multiarch = mkEnableOption "multiarch kernel-level support"; insecureWayland = mkEnableOption "direct access to the Wayland socket";
net = mkEnableOption "network access" // { net = mkEnableOption "network access" // {
default = true; default = true;
}; };
nix = mkEnableOption "nix daemon access"; compat = mkEnableOption "disable syscall filter extensions";
mapRealUid = mkEnableOption "mapping to priv-user uid"; devel = mkEnableOption "development kernel APIs";
dev = mkEnableOption "access to all devices"; multiarch = mkEnableOption "multiarch kernel support";
insecureWayland = mkEnableOption "direct access to the Wayland socket"; bluetooth = mkEnableOption "AF_BLUETOOTH socket operations";
gpu = mkOption { gpu = mkOption {
type = nullOr bool; type = nullOr bool;

View File

@ -4,6 +4,7 @@
buildGoModule, buildGoModule,
makeBinaryWrapper, makeBinaryWrapper,
xdg-dbus-proxy, xdg-dbus-proxy,
bubblewrap,
pkg-config, pkg-config,
libffi, libffi,
libseccomp, libseccomp,
@ -13,18 +14,6 @@
wayland-scanner, wayland-scanner,
xorg, xorg,
# for fpkg
zstd,
gnutar,
coreutils,
# for passthru.buildInputs
go,
gcc,
# for check
util-linux,
glibc, # for ldd glibc, # for ldd
withStatic ? stdenv.hostPlatform.isStatic, withStatic ? stdenv.hostPlatform.isStatic,
}: }:
@ -36,7 +25,10 @@ buildGoModule rec {
src = builtins.path { src = builtins.path {
name = "${pname}-src"; name = "${pname}-src";
path = lib.cleanSource ./.; path = lib.cleanSource ./.;
filter = path: type: !(type == "regular" && (lib.hasSuffix ".nix" path || lib.hasSuffix ".py" path)) && !(type == "directory" && lib.hasSuffix "/test" path) && !(type == "directory" && lib.hasSuffix "/cmd/fsu" path); filter =
path: type:
!(type == "regular" && lib.hasSuffix ".nix" path)
&& !(type == "directory" && lib.hasSuffix "/cmd/fsu" path);
}; };
vendorHash = null; vendorHash = null;
@ -47,15 +39,17 @@ buildGoModule rec {
ldflags ++ [ "-X git.gensokyo.uk/security/fortify/internal.${name}=${value}" ] ldflags ++ [ "-X git.gensokyo.uk/security/fortify/internal.${name}=${value}" ]
) )
( (
[ "-s -w" ] [
"-s -w"
]
++ lib.optionals withStatic [ ++ lib.optionals withStatic [
"-linkmode external" "-linkmode external"
"-extldflags \"-static\"" "-extldflags \"-static\""
] ]
) )
{ {
version = "v${version}"; Version = "v${version}";
fsu = "/run/wrappers/bin/fsu"; Fsu = "/run/wrappers/bin/fsu";
}; };
# nix build environment does not allow acls # nix build environment does not allow acls
@ -85,42 +79,19 @@ buildGoModule rec {
HOME="$(mktemp -d)" PATH="${pkg-config}/bin:$PATH" go generate ./... HOME="$(mktemp -d)" PATH="${pkg-config}/bin:$PATH" go generate ./...
''; '';
postInstall = postInstall = ''
let
appPackages = [
glibc
xdg-dbus-proxy
];
in
''
install -D --target-directory=$out/share/zsh/site-functions comp/* install -D --target-directory=$out/share/zsh/site-functions comp/*
mkdir "$out/libexec" mkdir "$out/libexec"
mv "$out"/bin/* "$out/libexec/" mv "$out"/bin/* "$out/libexec/"
makeBinaryWrapper "$out/libexec/fortify" "$out/bin/fortify" \ makeBinaryWrapper "$out/libexec/fortify" "$out/bin/fortify" \
--inherit-argv0 --prefix PATH : ${lib.makeBinPath appPackages}
makeBinaryWrapper "$out/libexec/fpkg" "$out/bin/fpkg" \
--inherit-argv0 --prefix PATH : ${ --inherit-argv0 --prefix PATH : ${
lib.makeBinPath ( lib.makeBinPath [
appPackages glibc
++ [ bubblewrap
zstd xdg-dbus-proxy
gnutar
coreutils
] ]
)
} }
''; '';
passthru.targetPkgs =
[
go
gcc
xorg.xorgproto
util-linux
]
++ buildInputs
++ nativeBuildInputs;
} }

View File

@ -50,12 +50,9 @@ func tryPath(name string) (config *fst.Config) {
func tryFd(name string) io.ReadCloser { func tryFd(name string) io.ReadCloser {
if v, err := strconv.Atoi(name); err != nil { if v, err := strconv.Atoi(name); err != nil {
if !errors.Is(err, strconv.ErrSyntax) {
fmsg.Verbosef("name cannot be interpreted as int64: %v", err) fmsg.Verbosef("name cannot be interpreted as int64: %v", err)
}
return nil return nil
} else { } else {
fmsg.Verbosef("trying config stream from %d", v)
fd := uintptr(v) fd := uintptr(v)
if _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, fd, syscall.F_GETFD, 0); errno != 0 { if _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, fd, syscall.F_GETFD, 0); errno != 0 {
if errors.Is(errno, syscall.EBADF) { if errors.Is(errno, syscall.EBADF) {

View File

@ -89,10 +89,10 @@ func printShowInstance(
flags = append(flags, name) flags = append(flags, name)
} }
} }
writeFlag("userns", sandbox.Userns) writeFlag("userns", sandbox.UserNS)
writeFlag("net", sandbox.Net) writeFlag("net", sandbox.Net)
writeFlag("dev", sandbox.Dev) writeFlag("dev", sandbox.Dev)
writeFlag("tty", sandbox.Tty) writeFlag("tty", sandbox.NoNewSession)
writeFlag("mapuid", sandbox.MapRealUID) writeFlag("mapuid", sandbox.MapRealUID)
writeFlag("directwl", sandbox.DirectWayland) writeFlag("directwl", sandbox.DirectWayland)
writeFlag("autoetc", sandbox.AutoEtc) writeFlag("autoetc", sandbox.AutoEtc)
@ -107,14 +107,14 @@ func printShowInstance(
} }
t.Printf(" Etc:\t%s\n", etc) t.Printf(" Etc:\t%s\n", etc)
if len(sandbox.Cover) > 0 { if len(sandbox.Override) > 0 {
t.Printf(" Cover:\t%s\n", strings.Join(sandbox.Cover, " ")) t.Printf(" Overrides:\t%s\n", strings.Join(sandbox.Override, " "))
} }
// Env map[string]string `json:"env"` // Env map[string]string `json:"env"`
// Link [][2]string `json:"symlink"` // Link [][2]string `json:"symlink"`
} }
t.Printf(" Command:\t%s\n", strings.Join(config.Args, " ")) t.Printf(" Command:\t%s\n", strings.Join(config.Command, " "))
t.Printf("\n") t.Printf("\n")
if !short { if !short {
@ -256,7 +256,7 @@ func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON boo
) )
if e.Config != nil { if e.Config != nil {
es = e.Config.Confinement.Enablements.String() es = e.Config.Confinement.Enablements.String()
cs = fmt.Sprintf("%q", e.Config.Args) cs = fmt.Sprintf("%q", e.Config.Command)
as = strconv.Itoa(e.Config.Confinement.AppID) as = strconv.Itoa(e.Config.Confinement.AppID)
} }
t.Printf("\t%s\t%d\t%s\t%s\t%s\t%s\n", t.Printf("\t%s\t%d\t%s\t%s\t%s\t%s\n",

View File

@ -37,13 +37,13 @@ func Test_printShowInstance(t *testing.T) {
}{ }{
{"config", nil, fst.Template(), false, false, `App {"config", nil, fst.Template(), false, false, `App
ID: 9 (org.chromium.Chromium) ID: 9 (org.chromium.Chromium)
Enablements: wayland, dbus, pulseaudio Enablements: Wayland, D-Bus, PulseAudio
Groups: ["video"] Groups: ["video"]
Directory: /var/lib/persist/home/org.chromium.Chromium Directory: /var/lib/persist/home/org.chromium.Chromium
Hostname: "localhost" Hostname: "localhost"
Flags: userns net dev tty mapuid autoetc Flags: userns net dev tty mapuid autoetc
Etc: /etc Etc: /etc
Cover: /var/run/nscd Overrides: /var/run/nscd
Command: chromium --ignore-gpu-blocklist --disable-smooth-scrolling --enable-features=UseOzonePlatform --ozone-platform=wayland Command: chromium --ignore-gpu-blocklist --disable-smooth-scrolling --enable-features=UseOzonePlatform --ozone-platform=wayland
Filesystem Filesystem
@ -74,14 +74,14 @@ System bus
App App
ID: 0 ID: 0
Enablements: (no enablements) Enablements: (No enablements)
Directory: Directory:
Command: Command:
`}, `},
{"config flag none", nil, &fst.Config{Confinement: fst.ConfinementConfig{Sandbox: new(fst.SandboxConfig)}}, false, false, `App {"config flag none", nil, &fst.Config{Confinement: fst.ConfinementConfig{Sandbox: new(fst.SandboxConfig)}}, false, false, `App
ID: 0 ID: 0
Enablements: (no enablements) Enablements: (No enablements)
Directory: Directory:
Flags: none Flags: none
Etc: /etc Etc: /etc
@ -90,7 +90,7 @@ App
`}, `},
{"config nil entries", nil, &fst.Config{Confinement: fst.ConfinementConfig{Sandbox: &fst.SandboxConfig{Filesystem: make([]*fst.FilesystemConfig, 1)}, ExtraPerms: make([]*fst.ExtraPermConfig, 1)}}, false, false, `App {"config nil entries", nil, &fst.Config{Confinement: fst.ConfinementConfig{Sandbox: &fst.SandboxConfig{Filesystem: make([]*fst.FilesystemConfig, 1)}, ExtraPerms: make([]*fst.ExtraPermConfig, 1)}}, false, false, `App
ID: 0 ID: 0
Enablements: (no enablements) Enablements: (No enablements)
Directory: Directory:
Flags: none Flags: none
Etc: /etc Etc: /etc
@ -105,7 +105,7 @@ Extra ACL
App App
ID: 0 ID: 0
Enablements: (no enablements) Enablements: (No enablements)
Directory: Directory:
Command: Command:
@ -121,13 +121,13 @@ Session bus
App App
ID: 9 (org.chromium.Chromium) ID: 9 (org.chromium.Chromium)
Enablements: wayland, dbus, pulseaudio Enablements: Wayland, D-Bus, PulseAudio
Groups: ["video"] Groups: ["video"]
Directory: /var/lib/persist/home/org.chromium.Chromium Directory: /var/lib/persist/home/org.chromium.Chromium
Hostname: "localhost" Hostname: "localhost"
Flags: userns net dev tty mapuid autoetc Flags: userns net dev tty mapuid autoetc
Etc: /etc Etc: /etc
Cover: /var/run/nscd Overrides: /var/run/nscd
Command: chromium --ignore-gpu-blocklist --disable-smooth-scrolling --enable-features=UseOzonePlatform --ozone-platform=wayland Command: chromium --ignore-gpu-blocklist --disable-smooth-scrolling --enable-features=UseOzonePlatform --ozone-platform=wayland
Filesystem Filesystem
@ -162,7 +162,7 @@ State
App App
ID: 0 ID: 0
Enablements: (no enablements) Enablements: (No enablements)
Directory: Directory:
Command: Command:
@ -192,8 +192,7 @@ App
"pid": 3735928559, "pid": 3735928559,
"config": { "config": {
"id": "org.chromium.Chromium", "id": "org.chromium.Chromium",
"path": "/run/current-system/sw/bin/chromium", "command": [
"args": [
"chromium", "chromium",
"--ignore-gpu-blocklist", "--ignore-gpu-blocklist",
"--disable-smooth-scrolling", "--disable-smooth-scrolling",
@ -210,19 +209,24 @@ App
"home": "/var/lib/persist/home/org.chromium.Chromium", "home": "/var/lib/persist/home/org.chromium.Chromium",
"sandbox": { "sandbox": {
"hostname": "localhost", "hostname": "localhost",
"seccomp": 32,
"devel": true,
"userns": true, "userns": true,
"net": true, "net": true,
"tty": true, "dev": true,
"syscall": {
"compat": false,
"deny_devel": true,
"multiarch": true, "multiarch": true,
"linux32": false,
"can": false,
"bluetooth": false
},
"no_new_session": true,
"map_real_uid": true,
"env": { "env": {
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", "GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com", "GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT" "GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT"
}, },
"map_real_uid": true,
"dev": true,
"filesystem": [ "filesystem": [
{ {
"src": "/nix/store" "src": "/nix/store"
@ -255,7 +259,7 @@ App
], ],
"etc": "/etc", "etc": "/etc",
"auto_etc": true, "auto_etc": true,
"cover": [ "override": [
"/var/run/nscd" "/var/run/nscd"
] ]
}, },
@ -316,8 +320,7 @@ App
`}, `},
{"json config", nil, fst.Template(), false, true, `{ {"json config", nil, fst.Template(), false, true, `{
"id": "org.chromium.Chromium", "id": "org.chromium.Chromium",
"path": "/run/current-system/sw/bin/chromium", "command": [
"args": [
"chromium", "chromium",
"--ignore-gpu-blocklist", "--ignore-gpu-blocklist",
"--disable-smooth-scrolling", "--disable-smooth-scrolling",
@ -334,19 +337,24 @@ App
"home": "/var/lib/persist/home/org.chromium.Chromium", "home": "/var/lib/persist/home/org.chromium.Chromium",
"sandbox": { "sandbox": {
"hostname": "localhost", "hostname": "localhost",
"seccomp": 32,
"devel": true,
"userns": true, "userns": true,
"net": true, "net": true,
"tty": true, "dev": true,
"syscall": {
"compat": false,
"deny_devel": true,
"multiarch": true, "multiarch": true,
"linux32": false,
"can": false,
"bluetooth": false
},
"no_new_session": true,
"map_real_uid": true,
"env": { "env": {
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", "GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com", "GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT" "GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT"
}, },
"map_real_uid": true,
"dev": true,
"filesystem": [ "filesystem": [
{ {
"src": "/nix/store" "src": "/nix/store"
@ -379,7 +387,7 @@ App
], ],
"etc": "/etc", "etc": "/etc",
"auto_etc": true, "auto_etc": true,
"cover": [ "override": [
"/var/run/nscd" "/var/run/nscd"
] ]
}, },
@ -470,7 +478,7 @@ func Test_printPs(t *testing.T) {
`}, `},
{"valid", state.Entries{testID: testState}, false, false, ` Instance PID App Uptime Enablements Command {"valid", state.Entries{testID: testState}, false, false, ` Instance PID App Uptime Enablements Command
8e2c76b0 3735928559 9 1h2m32s wayland, dbus, pulseaudio ["chromium" "--ignore-gpu-blocklist" "--disable-smooth-scrolling" "--enable-features=UseOzonePlatform" "--ozone-platform=wayland"] 8e2c76b0 3735928559 9 1h2m32s Wayland, D-Bus, PulseAudio ["chromium" "--ignore-gpu-blocklist" "--disable-smooth-scrolling" "--enable-features=UseOzonePlatform" "--ozone-platform=wayland"]
`}, `},
{"valid short", state.Entries{testID: testState}, true, false, `8e2c76b0 {"valid short", state.Entries{testID: testState}, true, false, `8e2c76b0
@ -498,8 +506,7 @@ func Test_printPs(t *testing.T) {
"pid": 3735928559, "pid": 3735928559,
"config": { "config": {
"id": "org.chromium.Chromium", "id": "org.chromium.Chromium",
"path": "/run/current-system/sw/bin/chromium", "command": [
"args": [
"chromium", "chromium",
"--ignore-gpu-blocklist", "--ignore-gpu-blocklist",
"--disable-smooth-scrolling", "--disable-smooth-scrolling",
@ -516,19 +523,24 @@ func Test_printPs(t *testing.T) {
"home": "/var/lib/persist/home/org.chromium.Chromium", "home": "/var/lib/persist/home/org.chromium.Chromium",
"sandbox": { "sandbox": {
"hostname": "localhost", "hostname": "localhost",
"seccomp": 32,
"devel": true,
"userns": true, "userns": true,
"net": true, "net": true,
"tty": true, "dev": true,
"syscall": {
"compat": false,
"deny_devel": true,
"multiarch": true, "multiarch": true,
"linux32": false,
"can": false,
"bluetooth": false
},
"no_new_session": true,
"map_real_uid": true,
"env": { "env": {
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY", "GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com", "GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT" "GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT"
}, },
"map_real_uid": true,
"dev": true,
"filesystem": [ "filesystem": [
{ {
"src": "/nix/store" "src": "/nix/store"
@ -561,7 +573,7 @@ func Test_printPs(t *testing.T) {
], ],
"etc": "/etc", "etc": "/etc",
"auto_etc": true, "auto_etc": true,
"cover": [ "override": [
"/var/run/nscd" "/var/run/nscd"
] ]
}, },

View File

@ -1,252 +0,0 @@
// Package sandbox implements unprivileged Linux container with hardening options useful for creating application sandboxes.
package sandbox
import (
"context"
"encoding/gob"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path"
"strconv"
"syscall"
"time"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
)
type HardeningFlags uintptr
const (
FSyscallCompat HardeningFlags = 1 << iota
FAllowDevel
FAllowUserns
FAllowTTY
FAllowNet
)
func (flags HardeningFlags) seccomp(opts seccomp.SyscallOpts) seccomp.SyscallOpts {
if flags&FSyscallCompat == 0 {
opts |= seccomp.FlagExt
}
if flags&FAllowDevel == 0 {
opts |= seccomp.FlagDenyDevel
}
if flags&FAllowUserns == 0 {
opts |= seccomp.FlagDenyNS
}
if flags&FAllowTTY == 0 {
opts |= seccomp.FlagDenyTTY
}
return opts
}
type (
// Container represents a container environment being prepared or run.
// None of [Container] methods are safe for concurrent use.
Container struct {
// Name of initial process in the container.
name string
// Cgroup fd, nil to disable.
Cgroup *int
// ExtraFiles passed through to initial process in the container,
// with behaviour identical to its [exec.Cmd] counterpart.
ExtraFiles []*os.File
// Custom [exec.Cmd] initialisation function.
CommandContext func(ctx context.Context) (cmd *exec.Cmd)
// param encoder for shim and init
setup *gob.Encoder
// cancels cmd
cancel context.CancelFunc
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
Cancel func(cmd *exec.Cmd) error
WaitDelay time.Duration
cmd *exec.Cmd
ctx context.Context
Params
}
// Params holds container configuration and is safe to serialise.
Params struct {
// Working directory in the container.
Dir string
// Initial process environment.
Env []string
// Absolute path of initial process in the container. Overrides name.
Path string
// Initial process argv.
Args []string
// Mapped Uid in user namespace.
Uid int
// Mapped Gid in user namespace.
Gid int
// Hostname value in UTS namespace.
Hostname string
// Sequential container setup ops.
*Ops
// Extra seccomp options.
Seccomp seccomp.SyscallOpts
// Permission bits of newly created parent directories.
// The zero value is interpreted as 0755.
ParentPerm os.FileMode
Flags HardeningFlags
}
Ops []Op
Op interface {
early(params *Params) error
apply(params *Params) error
prefix() string
Is(op Op) bool
fmt.Stringer
}
)
func (p *Container) Start() error {
if p.cmd != nil {
return errors.New("sandbox: already started")
}
if p.Ops == nil || len(*p.Ops) == 0 {
return errors.New("sandbox: starting an empty container")
}
ctx, cancel := context.WithCancel(p.ctx)
p.cancel = cancel
var cloneFlags uintptr = syscall.CLONE_NEWIPC |
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWCGROUP
if p.Flags&FAllowNet == 0 {
cloneFlags |= syscall.CLONE_NEWNET
}
// map to overflow id to work around ownership checks
if p.Uid < 1 {
p.Uid = OverflowUid()
}
if p.Gid < 1 {
p.Gid = OverflowGid()
}
if p.CommandContext != nil {
p.cmd = p.CommandContext(ctx)
} else {
p.cmd = exec.CommandContext(ctx, MustExecutable())
p.cmd.Args = []string{"init"}
}
p.cmd.Stdin, p.cmd.Stdout, p.cmd.Stderr = p.Stdin, p.Stdout, p.Stderr
p.cmd.WaitDelay = p.WaitDelay
if p.Cancel != nil {
p.cmd.Cancel = func() error { return p.Cancel(p.cmd) }
} else {
p.cmd.Cancel = func() error { return p.cmd.Process.Signal(syscall.SIGTERM) }
}
p.cmd.Dir = "/"
p.cmd.SysProcAttr = &syscall.SysProcAttr{
Setsid: p.Flags&FAllowTTY == 0,
Pdeathsig: syscall.SIGKILL,
Cloneflags: cloneFlags |
syscall.CLONE_NEWUSER |
syscall.CLONE_NEWPID |
syscall.CLONE_NEWNS,
// remain privileged for setup
AmbientCaps: []uintptr{CAP_SYS_ADMIN},
UseCgroupFD: p.Cgroup != nil,
}
if p.cmd.SysProcAttr.UseCgroupFD {
p.cmd.SysProcAttr.CgroupFD = *p.Cgroup
}
// place setup pipe before user supplied extra files, this is later restored by init
if fd, e, err := Setup(&p.cmd.ExtraFiles); err != nil {
return wrapErrSuffix(err,
"cannot create shim setup pipe:")
} else {
p.setup = e
p.cmd.Env = []string{setupEnv + "=" + strconv.Itoa(fd)}
}
p.cmd.ExtraFiles = append(p.cmd.ExtraFiles, p.ExtraFiles...)
msg.Verbose("starting container init")
if err := p.cmd.Start(); err != nil {
return msg.WrapErr(err, err.Error())
}
return nil
}
func (p *Container) Serve() error {
if p.setup == nil {
panic("invalid serve")
}
setup := p.setup
p.setup = nil
if p.Path != "" && !path.IsAbs(p.Path) {
p.cancel()
return msg.WrapErr(syscall.EINVAL,
fmt.Sprintf("invalid executable path %q", p.Path))
}
if p.Path == "" {
if p.name == "" {
p.Path = os.Getenv("SHELL")
if !path.IsAbs(p.Path) {
p.cancel()
return msg.WrapErr(syscall.EBADE,
"no command specified and $SHELL is invalid")
}
p.name = path.Base(p.Path)
} else if path.IsAbs(p.name) {
p.Path = p.name
} else if v, err := exec.LookPath(p.name); err != nil {
p.cancel()
return msg.WrapErr(err, err.Error())
} else {
p.Path = v
}
}
err := setup.Encode(
&initParams{
p.Params,
syscall.Getuid(),
syscall.Getgid(),
len(p.ExtraFiles),
msg.IsVerbose(),
},
)
if err != nil {
p.cancel()
}
return err
}
func (p *Container) Wait() error { defer p.cancel(); return p.cmd.Wait() }
func (p *Container) String() string {
return fmt.Sprintf("argv: %q, flags: %#x, seccomp: %#x",
p.Args, p.Flags, int(p.Flags.seccomp(p.Seccomp)))
}
func New(ctx context.Context, name string, args ...string) *Container {
return &Container{name: name, ctx: ctx,
Params: Params{Args: append([]string{name}, args...), Dir: "/", Ops: new(Ops)},
}
}

View File

@ -1,255 +0,0 @@
package sandbox_test
import (
"bytes"
"context"
"encoding/gob"
"log"
"os"
"os/exec"
"strings"
"syscall"
"testing"
"time"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/ldd"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
"git.gensokyo.uk/security/fortify/sandbox/vfs"
)
const (
ignore = "\x00"
ignoreV = -1
)
func TestContainer(t *testing.T) {
{
oldVerbose := fmsg.Load()
oldOutput := sandbox.GetOutput()
internal.InstallFmsg(true)
t.Cleanup(func() { fmsg.Store(oldVerbose) })
t.Cleanup(func() { sandbox.SetOutput(oldOutput) })
}
testCases := []struct {
name string
flags sandbox.HardeningFlags
ops *sandbox.Ops
mnt []*vfs.MountInfoEntry
host string
}{
{"minimal", 0, new(sandbox.Ops), nil, "test-minimal"},
{"allow", sandbox.FAllowUserns | sandbox.FAllowNet | sandbox.FAllowTTY,
new(sandbox.Ops), nil, "test-minimal"},
{"tmpfs", 0,
new(sandbox.Ops).
Tmpfs(fst.Tmp, 0, 0755),
[]*vfs.MountInfoEntry{
e("/", fst.Tmp, "rw,nosuid,nodev,relatime", "tmpfs", "tmpfs", ignore),
}, "test-tmpfs"},
{"dev", sandbox.FAllowTTY, // go test output is not a tty
new(sandbox.Ops).
Dev("/dev").
Mqueue("/dev/mqueue"),
[]*vfs.MountInfoEntry{
e("/", "/dev", "rw,nosuid,nodev,relatime", "tmpfs", "devtmpfs", ignore),
e("/null", "/dev/null", "rw,nosuid", "devtmpfs", "devtmpfs", ignore),
e("/zero", "/dev/zero", "rw,nosuid", "devtmpfs", "devtmpfs", ignore),
e("/full", "/dev/full", "rw,nosuid", "devtmpfs", "devtmpfs", ignore),
e("/random", "/dev/random", "rw,nosuid", "devtmpfs", "devtmpfs", ignore),
e("/urandom", "/dev/urandom", "rw,nosuid", "devtmpfs", "devtmpfs", ignore),
e("/tty", "/dev/tty", "rw,nosuid", "devtmpfs", "devtmpfs", ignore),
e("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"),
e("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"),
}, ""},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
container := sandbox.New(ctx, "/usr/bin/sandbox.test", "-test.v",
"-test.run=TestHelperCheckContainer", "--", "check", tc.host)
container.Uid = 1000
container.Gid = 100
container.Hostname = tc.host
container.CommandContext = commandContext
container.Flags |= tc.flags
container.Stdout, container.Stderr = os.Stdout, os.Stderr
container.Ops = tc.ops
if container.Args[5] == "" {
if name, err := os.Hostname(); err != nil {
t.Fatalf("cannot get hostname: %v", err)
} else {
container.Args[5] = name
}
}
container.
Tmpfs("/tmp", 0, 0755).
Bind(os.Args[0], os.Args[0], 0).
Mkdir("/usr/bin", 0755).
Link(os.Args[0], "/usr/bin/sandbox.test").
Place("/etc/hostname", []byte(container.Args[5]))
// in case test has cgo enabled
var libPaths []string
if entries, err := ldd.ExecFilter(ctx,
commandContext,
func(v []byte) []byte {
return bytes.SplitN(v, []byte("TestHelperInit\n"), 2)[1]
}, os.Args[0]); err != nil {
log.Fatalf("ldd: %v", err)
} else {
libPaths = ldd.Path(entries)
}
for _, name := range libPaths {
container.Bind(name, name, 0)
}
// needs /proc to check mountinfo
container.Proc("/proc")
mnt := make([]*vfs.MountInfoEntry, 0, 3+len(libPaths))
mnt = append(mnt, e("/sysroot", "/", "rw,nosuid,nodev,relatime", "tmpfs", "rootfs", ignore))
mnt = append(mnt, tc.mnt...)
mnt = append(mnt,
e("/", "/tmp", "rw,nosuid,nodev,relatime", "tmpfs", "tmpfs", ignore),
e(ignore, os.Args[0], "ro,nosuid,nodev,relatime", ignore, ignore, ignore),
e(ignore, "/etc/hostname", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", ignore),
)
for _, name := range libPaths {
mnt = append(mnt, e(ignore, name, "ro,nosuid,nodev,relatime", ignore, ignore, ignore))
}
mnt = append(mnt, e("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"))
want := new(bytes.Buffer)
if err := gob.NewEncoder(want).Encode(mnt); err != nil {
t.Fatalf("cannot serialise expected mount points: %v", err)
}
container.Stdin = want
if err := container.Start(); err != nil {
fmsg.PrintBaseError(err, "start:")
t.Fatalf("cannot start container: %v", err)
} else if err = container.Serve(); err != nil {
fmsg.PrintBaseError(err, "serve:")
t.Errorf("cannot serve setup params: %v", err)
}
if err := container.Wait(); err != nil {
fmsg.PrintBaseError(err, "wait:")
t.Fatalf("wait: %v", err)
}
})
}
}
func e(root, target, vfsOptstr, fsType, source, fsOptstr string) *vfs.MountInfoEntry {
return &vfs.MountInfoEntry{
ID: ignoreV,
Parent: ignoreV,
Devno: vfs.DevT{ignoreV, ignoreV},
Root: root,
Target: target,
VfsOptstr: vfsOptstr,
OptFields: []string{ignore},
FsType: fsType,
Source: source,
FsOptstr: fsOptstr,
}
}
func TestContainerString(t *testing.T) {
container := sandbox.New(context.TODO(), "ldd", "/usr/bin/env")
container.Flags |= sandbox.FAllowDevel
container.Seccomp |= seccomp.FlagMultiarch
want := `argv: ["ldd" "/usr/bin/env"], flags: 0x2, seccomp: 0x2e`
if got := container.String(); got != want {
t.Errorf("String: %s, want %s", got, want)
}
}
func TestHelperInit(t *testing.T) {
if len(os.Args) != 5 || os.Args[4] != "init" {
return
}
sandbox.SetOutput(fmsg.Output{})
sandbox.Init(fmsg.Prepare, internal.InstallFmsg)
}
func TestHelperCheckContainer(t *testing.T) {
if len(os.Args) != 6 || os.Args[4] != "check" {
return
}
t.Run("user", func(t *testing.T) {
if uid := syscall.Getuid(); uid != 1000 {
t.Errorf("Getuid: %d, want 1000", uid)
}
if gid := syscall.Getgid(); gid != 100 {
t.Errorf("Getgid: %d, want 100", gid)
}
})
t.Run("hostname", func(t *testing.T) {
if name, err := os.Hostname(); err != nil {
t.Fatalf("cannot get hostname: %v", err)
} else if name != os.Args[5] {
t.Errorf("Hostname: %q, want %q", name, os.Args[5])
}
if p, err := os.ReadFile("/etc/hostname"); err != nil {
t.Fatalf("%v", err)
} else if string(p) != os.Args[5] {
t.Errorf("/etc/hostname: %q, want %q", string(p), os.Args[5])
}
})
t.Run("mount", func(t *testing.T) {
var mnt []*vfs.MountInfoEntry
if err := gob.NewDecoder(os.Stdin).Decode(&mnt); err != nil {
t.Fatalf("cannot receive expected mount points: %v", err)
}
var d *vfs.MountInfoDecoder
if f, err := os.Open("/proc/self/mountinfo"); err != nil {
t.Fatalf("cannot open mountinfo: %v", err)
} else {
d = vfs.NewMountInfoDecoder(f)
}
i := 0
for cur := range d.Entries() {
if i == len(mnt) {
t.Errorf("got more than %d entries", len(mnt))
break
}
// ugly hack but should be reliable and is less likely to false negative than comparing by parsed flags
cur.VfsOptstr = strings.TrimSuffix(cur.VfsOptstr, ",relatime")
cur.VfsOptstr = strings.TrimSuffix(cur.VfsOptstr, ",noatime")
mnt[i].VfsOptstr = strings.TrimSuffix(mnt[i].VfsOptstr, ",relatime")
mnt[i].VfsOptstr = strings.TrimSuffix(mnt[i].VfsOptstr, ",noatime")
if !cur.EqualWithIgnore(mnt[i], "\x00") {
t.Errorf("[FAIL] %s", cur)
} else {
t.Logf("[ OK ] %s", cur)
}
i++
}
if err := d.Err(); err != nil {
t.Errorf("cannot parse mountinfo: %v", err)
}
if i != len(mnt) {
t.Errorf("got %d entries, want %d", i, len(mnt))
}
})
}
func commandContext(ctx context.Context) *exec.Cmd {
return exec.CommandContext(ctx, os.Args[0], "-test.v",
"-test.run=TestHelperInit", "--", "init")
}

View File

@ -1,17 +0,0 @@
package sandbox_test
import (
"os"
"testing"
"git.gensokyo.uk/security/fortify/sandbox"
)
func TestExecutable(t *testing.T) {
for i := 0; i < 16; i++ {
if got := sandbox.MustExecutable(); got != os.Args[0] {
t.Errorf("MustExecutable: %q, want %q",
got, os.Args[0])
}
}
}

View File

@ -1,362 +0,0 @@
package sandbox
import (
"errors"
"fmt"
"log"
"os"
"os/exec"
"os/signal"
"path"
"runtime"
"strconv"
"syscall"
"time"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
)
const (
// time to wait for linger processes after death of initial process
residualProcessTimeout = 5 * time.Second
// intermediate tmpfs mount point
basePath = "/tmp"
// setup params file descriptor
setupEnv = "FORTIFY_SETUP"
)
type initParams struct {
Params
HostUid, HostGid int
// extra files count
Count int
// verbosity pass through
Verbose bool
}
func Init(prepare func(prefix string), setVerbose func(verbose bool)) {
runtime.LockOSThread()
prepare("init")
if os.Getpid() != 1 {
log.Fatal("this process must run as pid 1")
}
/*
receive setup payload
*/
var (
params initParams
closeSetup func() error
setupFile *os.File
offsetSetup int
)
if f, err := Receive(setupEnv, &params, &setupFile); err != nil {
if errors.Is(err, ErrInvalid) {
log.Fatal("invalid setup descriptor")
}
if errors.Is(err, ErrNotSet) {
log.Fatal("FORTIFY_SETUP not set")
}
log.Fatalf("cannot decode init setup payload: %v", err)
} else {
if params.Ops == nil {
log.Fatal("invalid setup parameters")
}
if params.ParentPerm == 0 {
params.ParentPerm = 0755
}
setVerbose(params.Verbose)
msg.Verbose("received setup parameters")
closeSetup = f
offsetSetup = int(setupFile.Fd() + 1)
}
// write uid/gid map here so parent does not need to set dumpable
if err := SetDumpable(SUID_DUMP_USER); err != nil {
log.Fatalf("cannot set SUID_DUMP_USER: %s", err)
}
if err := os.WriteFile("/proc/self/uid_map",
append([]byte{}, strconv.Itoa(params.Uid)+" "+strconv.Itoa(params.HostUid)+" 1\n"...),
0); err != nil {
log.Fatalf("%v", err)
}
if err := os.WriteFile("/proc/self/setgroups",
[]byte("deny\n"),
0); err != nil && !os.IsNotExist(err) {
log.Fatalf("%v", err)
}
if err := os.WriteFile("/proc/self/gid_map",
append([]byte{}, strconv.Itoa(params.Gid)+" "+strconv.Itoa(params.HostGid)+" 1\n"...),
0); err != nil {
log.Fatalf("%v", err)
}
if err := SetDumpable(SUID_DUMP_DISABLE); err != nil {
log.Fatalf("cannot set SUID_DUMP_DISABLE: %s", err)
}
oldmask := syscall.Umask(0)
if params.Hostname != "" {
if err := syscall.Sethostname([]byte(params.Hostname)); err != nil {
log.Fatalf("cannot set hostname: %v", err)
}
}
/*
set up mount points from intermediate root
*/
if err := syscall.Mount("", "/", "",
syscall.MS_SILENT|syscall.MS_SLAVE|syscall.MS_REC,
""); err != nil {
log.Fatalf("cannot make / rslave: %v", err)
}
for i, op := range *params.Ops {
if op == nil {
log.Fatalf("invalid op %d", i)
}
if err := op.early(&params.Params); err != nil {
msg.PrintBaseErr(err,
fmt.Sprintf("cannot prepare op %d:", i))
msg.BeforeExit()
os.Exit(1)
}
}
if err := syscall.Mount("rootfs", basePath, "tmpfs",
syscall.MS_NODEV|syscall.MS_NOSUID,
""); err != nil {
log.Fatalf("cannot mount intermediate root: %v", err)
}
if err := os.Chdir(basePath); err != nil {
log.Fatalf("cannot enter base path: %v", err)
}
if err := os.Mkdir(sysrootDir, 0755); err != nil {
log.Fatalf("%v", err)
}
if err := syscall.Mount(sysrootDir, sysrootDir, "",
syscall.MS_SILENT|syscall.MS_MGC_VAL|syscall.MS_BIND|syscall.MS_REC,
""); err != nil {
log.Fatalf("cannot bind sysroot: %v", err)
}
if err := os.Mkdir(hostDir, 0755); err != nil {
log.Fatalf("%v", err)
}
if err := syscall.PivotRoot(basePath, hostDir); err != nil {
log.Fatalf("cannot pivot into intermediate root: %v", err)
}
if err := os.Chdir("/"); err != nil {
log.Fatalf("%v", err)
}
for i, op := range *params.Ops {
// ops already checked during early setup
msg.Verbosef("%s %s", op.prefix(), op)
if err := op.apply(&params.Params); err != nil {
msg.PrintBaseErr(err,
fmt.Sprintf("cannot apply op %d:", i))
msg.BeforeExit()
os.Exit(1)
}
}
/*
pivot to sysroot
*/
if err := syscall.Mount(hostDir, hostDir, "",
syscall.MS_SILENT|syscall.MS_REC|syscall.MS_PRIVATE,
""); err != nil {
log.Fatalf("cannot make host root rprivate: %v", err)
}
if err := syscall.Unmount(hostDir, syscall.MNT_DETACH); err != nil {
log.Fatalf("cannot unmount host root: %v", err)
}
{
var fd int
if err := IgnoringEINTR(func() (err error) {
fd, err = syscall.Open("/", syscall.O_DIRECTORY|syscall.O_RDONLY, 0)
return
}); err != nil {
log.Fatalf("cannot open intermediate root: %v", err)
}
if err := os.Chdir(sysrootPath); err != nil {
log.Fatalf("%v", err)
}
if err := syscall.PivotRoot(".", "."); err != nil {
log.Fatalf("cannot pivot into sysroot: %v", err)
}
if err := syscall.Fchdir(fd); err != nil {
log.Fatalf("cannot re-enter intermediate root: %v", err)
}
if err := syscall.Unmount(".", syscall.MNT_DETACH); err != nil {
log.Fatalf("cannot unmount intemediate root: %v", err)
}
if err := os.Chdir("/"); err != nil {
log.Fatalf("%v", err)
}
if err := syscall.Close(fd); err != nil {
log.Fatalf("cannot close intermediate root: %v", err)
}
}
/*
load seccomp filter
*/
if _, _, err := syscall.Syscall(PR_SET_NO_NEW_PRIVS, 1, 0, 0); err != 0 {
log.Fatalf("prctl(PR_SET_NO_NEW_PRIVS): %v", err)
}
if err := seccomp.Load(params.Flags.seccomp(params.Seccomp)); err != nil {
log.Fatalf("cannot load syscall filter: %v", err)
}
/* at this point CAP_SYS_ADMIN can be dropped, however it is kept for now as it does not increase attack surface */
/*
pass through extra files
*/
extraFiles := make([]*os.File, params.Count)
for i := range extraFiles {
extraFiles[i] = os.NewFile(uintptr(offsetSetup+i), "extra file "+strconv.Itoa(i))
}
syscall.Umask(oldmask)
/*
prepare initial process
*/
cmd := exec.Command(params.Path)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Args = params.Args
cmd.Env = params.Env
cmd.ExtraFiles = extraFiles
cmd.Dir = params.Dir
if err := cmd.Start(); err != nil {
log.Fatalf("%v", err)
}
msg.Suspend()
/*
close setup pipe
*/
if err := closeSetup(); err != nil {
log.Println("cannot close setup pipe:", err)
// not fatal
}
/*
perform init duties
*/
sig := make(chan os.Signal, 2)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
type winfo struct {
wpid int
wstatus syscall.WaitStatus
}
info := make(chan winfo, 1)
done := make(chan struct{})
go func() {
var (
err error
wpid = -2
wstatus syscall.WaitStatus
)
// keep going until no child process is left
for wpid != -1 {
if err != nil {
break
}
if wpid != -2 {
info <- winfo{wpid, wstatus}
}
err = syscall.EINTR
for errors.Is(err, syscall.EINTR) {
wpid, err = syscall.Wait4(-1, &wstatus, 0, nil)
}
}
if !errors.Is(err, syscall.ECHILD) {
log.Println("unexpected wait4 response:", err)
}
close(done)
}()
// closed after residualProcessTimeout has elapsed after initial process death
timeout := make(chan struct{})
r := 2
for {
select {
case s := <-sig:
if msg.Resume() {
msg.Verbosef("terminating on %s after process start", s.String())
} else {
msg.Verbosef("terminating on %s", s.String())
}
msg.BeforeExit()
os.Exit(0)
case w := <-info:
if w.wpid == cmd.Process.Pid {
// initial process exited, output is most likely available again
msg.Resume()
switch {
case w.wstatus.Exited():
r = w.wstatus.ExitStatus()
msg.Verbosef("initial process exited with code %d", w.wstatus.ExitStatus())
case w.wstatus.Signaled():
r = 128 + int(w.wstatus.Signal())
msg.Verbosef("initial process exited with signal %s", w.wstatus.Signal())
default:
r = 255
msg.Verbosef("initial process exited with status %#x", w.wstatus)
}
go func() {
time.Sleep(residualProcessTimeout)
close(timeout)
}()
}
case <-done:
msg.BeforeExit()
os.Exit(r)
case <-timeout:
log.Println("timeout exceeded waiting for lingering processes")
msg.BeforeExit()
os.Exit(r)
}
}
}
// TryArgv0 calls [Init] if the last element of argv0 is "init".
func TryArgv0(v Msg, prepare func(prefix string), setVerbose func(verbose bool)) {
if len(os.Args) > 0 && path.Base(os.Args[0]) == "init" {
msg = v
Init(prepare, setVerbose)
msg.BeforeExit()
os.Exit(0)
}
}

View File

@ -1,125 +0,0 @@
package sandbox
import (
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
"git.gensokyo.uk/security/fortify/sandbox/vfs"
)
func (p *procPaths) bindMount(source, target string, flags uintptr, eq bool) error {
if eq {
msg.Verbosef("resolved %q flags %#x", target, flags)
} else {
msg.Verbosef("resolved %q on %q flags %#x", source, target, flags)
}
if err := syscall.Mount(source, target, "",
syscall.MS_SILENT|syscall.MS_BIND|flags&syscall.MS_REC, ""); err != nil {
return wrapErrSuffix(err,
fmt.Sprintf("cannot mount %q on %q:", source, target))
}
var targetFinal string
if v, err := filepath.EvalSymlinks(target); err != nil {
return wrapErrSelf(err)
} else {
targetFinal = v
if targetFinal != target {
msg.Verbosef("target resolves to %q", targetFinal)
}
}
// final target path according to the kernel through proc
var targetKFinal string
{
var destFd int
if err := IgnoringEINTR(func() (err error) {
destFd, err = syscall.Open(targetFinal, O_PATH|syscall.O_CLOEXEC, 0)
return
}); err != nil {
return wrapErrSuffix(err,
fmt.Sprintf("cannot open %q:", targetFinal))
}
if v, err := os.Readlink(p.fd(destFd)); err != nil {
return wrapErrSelf(err)
} else if err = syscall.Close(destFd); err != nil {
return wrapErrSuffix(err,
fmt.Sprintf("cannot close %q:", targetFinal))
} else {
targetKFinal = v
}
}
mf := syscall.MS_NOSUID | flags&syscall.MS_NODEV | flags&syscall.MS_RDONLY
return hostProc.mountinfo(func(d *vfs.MountInfoDecoder) error {
n, err := d.Unfold(targetKFinal)
if err != nil {
if errors.Is(err, syscall.ESTALE) {
return msg.WrapErr(err,
fmt.Sprintf("mount point %q never appeared in mountinfo", targetKFinal))
}
return wrapErrSuffix(err,
"cannot unfold mount hierarchy:")
}
if err = remountWithFlags(n, mf); err != nil {
return err
}
if flags&syscall.MS_REC == 0 {
return nil
}
for cur := range n.Collective() {
err = remountWithFlags(cur, mf)
if err != nil && !errors.Is(err, syscall.EACCES) {
return err
}
}
return nil
})
}
func remountWithFlags(n *vfs.MountInfoNode, mf uintptr) error {
kf, unmatched := n.Flags()
if len(unmatched) != 0 {
msg.Verbosef("unmatched vfs options: %q", unmatched)
}
if kf&mf != mf {
return wrapErrSuffix(syscall.Mount("none", n.Clean, "",
syscall.MS_SILENT|syscall.MS_BIND|syscall.MS_REMOUNT|kf|mf,
""),
fmt.Sprintf("cannot remount %q:", n.Clean))
}
return nil
}
func mountTmpfs(fsname, name string, size int, perm os.FileMode) error {
target := toSysroot(name)
if err := os.MkdirAll(target, parentPerm(perm)); err != nil {
return wrapErrSelf(err)
}
opt := fmt.Sprintf("mode=%#o", perm)
if size > 0 {
opt += fmt.Sprintf(",size=%d", size)
}
return wrapErrSuffix(syscall.Mount(fsname, target, "tmpfs",
syscall.MS_NOSUID|syscall.MS_NODEV, opt),
fmt.Sprintf("cannot mount tmpfs on %q:", name))
}
func parentPerm(perm os.FileMode) os.FileMode {
pperm := 0755
if perm&0070 == 0 {
pperm &= ^0050
}
if perm&0007 == 0 {
pperm &= ^0005
}
return os.FileMode(pperm)
}

Some files were not shown because too many files have changed in this diff Show More