Compare commits

..

No commits in common. "master" and "v0.0.1" have entirely different histories.

100 changed files with 1774 additions and 5169 deletions

View File

@ -13,26 +13,22 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up go - name: Set up go
uses: https://github.com/actions/setup-go@v5 uses: https://github.com/actions/setup-go@v5
with: with:
go-version: '>=1.23.0' go-version: '>=1.20.1'
- name: Get dependencies - name: Get dependencies
run: >- run: >-
apt-get update && sudo apt-get update &&
apt-get install -y sudo apt-get install -y
gcc gcc
pkg-config pkg-config
libacl1-dev libacl1-dev
if: ${{ runner.os == 'Linux' }} if: ${{ runner.os == 'Linux' }}
- name: Build for Linux - name: Build for Linux
run: >- run: >-
sh -c "go build -v -ldflags '-s -w -X main.Version=${{ github.ref_name }}' -o bin/fortify && sh -c "go build -v -ldflags '-s -w -X main.Version=${{ github.ref_name }}' -o bin/fortify &&
sha256sum --tag -b bin/fortify > bin/fortify.sha256" sha256sum --tag -b bin/fortify > bin/fortify.sha256"
- name: Release - name: Release
id: use-go-action id: use-go-action
uses: https://gitea.com/actions/release-action@main uses: https://gitea.com/actions/release-action@main

View File

@ -1,37 +0,0 @@
name: test
on:
- push
- pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup go
uses: https://github.com/actions/setup-go@v5
with:
go-version: '>=1.23.0'
- name: Get dependencies
run: >-
apt-get update &&
apt-get install -y
gcc
pkg-config
libacl1-dev
if: ${{ runner.os == 'Linux' }}
- name: Run tests
run: >-
go test ./...
- name: Build for Linux
run: >-
sh -c "go build -v -ldflags '-s -w -X main.Version=${{ github.ref_name }}' -o bin/fortify &&
sha256sum --tag -b bin/fortify > bin/fortify.sha256"

View File

@ -1,9 +1,9 @@
Fortify Fortify
======= =======
[![Go Reference](https://pkg.go.dev/badge/git.ophivana.moe/security/fortify.svg)](https://pkg.go.dev/git.ophivana.moe/security/fortify) [![Go Reference](https://pkg.go.dev/badge/git.ophivana.moe/cat/fortify.svg)](https://pkg.go.dev/git.ophivana.moe/cat/fortify)
Lets you run graphical applications as another user in a confined environment with a nice NixOS Lets you run graphical applications as another user ~~in an Android-like sandbox environment~~ (WIP) with a nice NixOS
module to configure target users and provide launchers and desktop files for your privileged user. module to configure target users and provide launchers and desktop files for your privileged user.
Why would you want this? Why would you want this?
@ -12,7 +12,7 @@ Why would you want this?
- It protects applications from each other. - It protects applications from each other.
- It provides UID isolation on top of the standard application sandbox. - It provides UID isolation on top of ~~the standard application sandbox~~ (WIP).
There are a few different things to set up for this to work: There are a few different things to set up for this to work:
@ -26,7 +26,7 @@ There are a few different things to set up for this to work:
If you have a flakes-enabled nix environment, you can try out the tool by running: If you have a flakes-enabled nix environment, you can try out the tool by running:
```shell ```shell
nix run git+https://git.ophivana.moe/security/fortify -- -h nix run git+https://git.ophivana.moe/cat/fortify -- -h
``` ```
## Module usage ## Module usage
@ -41,7 +41,7 @@ To use the module, import it into your configuration with
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
fortify = { fortify = {
url = "git+https://git.ophivana.moe/security/fortify"; url = "git+https://git.ophivana.moe/cat/fortify";
# Optional but recommended to limit the size of your system closure. # Optional but recommended to limit the size of your system closure.
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
@ -69,6 +69,7 @@ This adds the `environment.fortify` option:
environment.fortify = { environment.fortify = {
enable = true; enable = true;
user = "nixos"; user = "nixos";
shell = "zsh";
stateDir = "/var/lib/persist/module"; stateDir = "/var/lib/persist/module";
target = { target = {
chronos = { chronos = {
@ -140,6 +141,8 @@ This adds the `environment.fortify` option:
* `user` specifies the privileged user with access to fortified applications. * `user` specifies the privileged user with access to fortified applications.
* `shell` is the shell used to run the launch command, required for sourcing the home-manager environment.
* `stateDir` is the path to your persistent storage location. It is directly passed through to the impermanence module. * `stateDir` is the path to your persistent storage location. It is directly passed through to the impermanence module.
* `target` is an attribute set of submodules, where the attribute name is the username of the unprivileged target user. * `target` is an attribute set of submodules, where the attribute name is the username of the unprivileged target user.

View File

@ -25,25 +25,7 @@ const (
Other = C.ACL_OTHER Other = C.ACL_OTHER
) )
type ( type Perm C.acl_perm_t
Perm C.acl_perm_t
Perms []Perm
)
func (ps Perms) String() string {
var s = []byte("---")
for _, p := range ps {
switch p {
case Read:
s[0] = 'r'
case Write:
s[1] = 'w'
case Execute:
s[2] = 'x'
}
}
return string(s)
}
func UpdatePerm(path string, uid int, perms ...Perm) error { func UpdatePerm(path string, uid int, perms ...Perm) error {
// read acl from file // read acl from file

View File

@ -1,140 +0,0 @@
package main
import (
"bufio"
"log"
"os"
"path"
"strconv"
"strings"
"syscall"
)
const (
fsuConfFile = "/etc/fsurc"
envShim = "FORTIFY_SHIM"
envAID = "FORTIFY_APP_ID"
fpPoison = "INVALIDINVALIDINVALIDINVALIDINVALID"
)
// FortifyPath is the path to fortify, set at compile time.
var FortifyPath = fpPoison
func main() {
log.SetFlags(0)
log.SetPrefix("fsu: ")
log.SetOutput(os.Stderr)
if os.Geteuid() != 0 {
log.Fatal("this program must be owned by uid 0 and have the setuid bit set")
}
puid := os.Getuid()
if puid == 0 {
log.Fatal("this program must not be started by root")
}
// validate compiled in fortify path
if FortifyPath == fpPoison || !path.IsAbs(FortifyPath) {
log.Fatal("invalid fortify path, this copy of fsu is not compiled correctly")
}
pexe := path.Join("/proc", strconv.Itoa(os.Getppid()), "exe")
if p, err := os.Readlink(pexe); err != nil {
log.Fatalf("cannot read parent executable path: %v", err)
} else if strings.HasSuffix(p, " (deleted)") {
log.Fatal("fortify executable has been deleted")
} else if p != FortifyPath {
log.Fatal("this program must be started by fortify")
}
// uid = 1000000 +
// fid * 10000 +
// aid
uid := 1000000
// authenticate before accepting user input
if fid, ok := parseConfig(fsuConfFile, puid); !ok {
log.Fatalf("uid %d is not in the fsurc file", puid)
} else {
uid += fid * 10000
}
// pass through setup path to shim
var shimSetupPath string
if s, ok := os.LookupEnv(envShim); !ok {
log.Fatal("FORTIFY_SHIM not set")
} else if !path.IsAbs(s) {
log.Fatal("FORTIFY_SHIM is not absolute")
} else {
shimSetupPath = s
}
// allowed aid range 0 to 9999
if as, ok := os.LookupEnv(envAID); !ok {
log.Fatal("FORTIFY_APP_ID not set")
} else if aid, err := strconv.Atoi(as); err != nil || aid < 0 || aid > 9999 {
log.Fatal("invalid aid")
} else {
uid += aid
}
if err := syscall.Setresgid(uid, uid, uid); err != nil {
log.Fatalf("cannot set gid: %v", err)
}
if err := syscall.Setresuid(uid, uid, uid); err != nil {
log.Fatalf("cannot set uid: %v", err)
}
if err := syscall.Exec(FortifyPath, []string{"fortify", "shim"}, []string{envShim + "=" + shimSetupPath}); err != nil {
log.Fatalf("cannot start shim: %v", err)
}
panic("unreachable")
}
func parseConfig(p string, puid int) (fid int, ok bool) {
// refuse to run if fsurc is not protected correctly
if s, err := os.Stat(p); err != nil {
log.Fatal(err)
} else if s.Mode().Perm() != 0400 {
log.Fatal("bad fsurc perm")
} else if st := s.Sys().(*syscall.Stat_t); st.Uid != 0 || st.Gid != 0 {
log.Fatal("fsurc must be owned by uid 0")
}
if r, err := os.Open(p); err != nil {
log.Fatal(err)
return -1, false
} else {
s := bufio.NewScanner(r)
var line int
for s.Scan() {
line++
// <puid> <fid>
lf := strings.SplitN(s.Text(), " ", 2)
if len(lf) != 2 {
log.Fatalf("invalid entry on line %d", line)
}
var puid0 int
if puid0, err = strconv.Atoi(lf[0]); err != nil || puid0 < 1 {
log.Fatalf("invalid parent uid on line %d", line)
}
ok = puid0 == puid
if ok {
// allowed fid range 0 to 99
if fid, err = strconv.Atoi(lf[1]); err != nil || fid < 0 || fid > 99 {
log.Fatalf("invalid fortify uid on line %d", line)
}
return
}
}
if err = s.Err(); err != nil {
log.Fatalf("cannot read fsurc: %v", err)
}
return -1, false
}
}

135
config.go
View File

@ -1,135 +0,0 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"git.ophivana.moe/security/fortify/dbus"
"git.ophivana.moe/security/fortify/internal/app"
"git.ophivana.moe/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/system"
)
var (
printTemplate bool
confPath string
dbusConfigSession string
dbusConfigSystem string
dbusID string
mpris bool
dbusVerbose bool
userName string
enablements [system.ELen]bool
launchMethodText string
)
func init() {
flag.BoolVar(&printTemplate, "template", false, "Print a full config template and exit")
// config file, disables every other flag here
flag.StringVar(&confPath, "c", "nil", "Path to full app configuration, or \"nil\" to configure from flags")
flag.StringVar(&dbusConfigSession, "dbus-config", "builtin", "Path to D-Bus proxy config file, or \"builtin\" for defaults")
flag.StringVar(&dbusConfigSystem, "dbus-system", "nil", "Path to system D-Bus proxy config file, or \"nil\" to disable")
flag.StringVar(&dbusID, "dbus-id", "", "D-Bus ID of application, leave empty to disable own paths, has no effect if custom config is available")
flag.BoolVar(&mpris, "mpris", false, "Allow owning MPRIS D-Bus path, has no effect if custom config is available")
flag.BoolVar(&dbusVerbose, "dbus-log", false, "Force logging in the D-Bus proxy")
flag.StringVar(&userName, "u", "chronos", "Passwd name of user to run as")
flag.BoolVar(&enablements[system.EWayland], "wayland", false, "Share Wayland socket")
flag.BoolVar(&enablements[system.EX11], "X", false, "Share X11 socket and allow connection")
flag.BoolVar(&enablements[system.EDBus], "dbus", false, "Proxy D-Bus connection")
flag.BoolVar(&enablements[system.EPulse], "pulse", false, "Share PulseAudio socket and cookie")
}
func init() {
methodHelpString := "Method of launching the child process, can be one of \"sudo\""
if os.SdBooted() {
methodHelpString += ", \"systemd\""
}
flag.StringVar(&launchMethodText, "method", "sudo", methodHelpString)
}
func tryTemplate() {
if printTemplate {
if s, err := json.MarshalIndent(app.Template(), "", " "); err != nil {
fmsg.Fatalf("cannot generate template: %v", err)
panic("unreachable")
} else {
fmt.Println(string(s))
}
fmsg.Exit(0)
}
}
func loadConfig() *app.Config {
if confPath == "nil" {
// config from flags
return configFromFlags()
} else {
// config from file
c := new(app.Config)
if f, err := os.Open(confPath); err != nil {
fmsg.Fatalf("cannot access config file %q: %s", confPath, err)
panic("unreachable")
} else if err = json.NewDecoder(f).Decode(&c); err != nil {
fmsg.Fatalf("cannot parse config file %q: %s", confPath, err)
panic("unreachable")
} else {
return c
}
}
}
func configFromFlags() (config *app.Config) {
// initialise config from flags
config = &app.Config{
ID: dbusID,
User: userName,
Command: flag.Args(),
Method: launchMethodText,
}
// enablements from flags
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if enablements[i] {
config.Confinement.Enablements.Set(i)
}
}
// parse D-Bus config file from flags if applicable
if enablements[system.EDBus] {
if dbusConfigSession == "builtin" {
config.Confinement.SessionBus = dbus.NewConfig(dbusID, true, mpris)
} else {
if c, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil {
fmsg.Fatalf("cannot load session bus proxy config from %q: %s", dbusConfigSession, err)
} else {
config.Confinement.SessionBus = c
}
}
// system bus proxy is optional
if dbusConfigSystem != "nil" {
if c, err := dbus.NewConfigFromFile(dbusConfigSystem); err != nil {
fmsg.Fatalf("cannot load system bus proxy config from %q: %s", dbusConfigSystem, err)
} else {
config.Confinement.SystemBus = c
}
}
// override log from configuration
if dbusVerbose {
config.Confinement.SessionBus.Log = true
config.Confinement.SystemBus.Log = true
}
}
return
}

View File

@ -9,7 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.ophivana.moe/security/fortify/dbus" "git.ophivana.moe/cat/fortify/dbus"
) )
func TestConfig_Args(t *testing.T) { func TestConfig_Args(t *testing.T) {

View File

@ -5,8 +5,8 @@ import (
"strings" "strings"
"testing" "testing"
"git.ophivana.moe/security/fortify/dbus" "git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
) )
func TestNew(t *testing.T) { func TestNew(t *testing.T) {
@ -137,15 +137,6 @@ func testProxyStartWaitCloseString(t *testing.T, sandbox bool) {
} }
}) })
t.Run("unsealed start of "+id, func(t *testing.T) {
want := "proxy not sealed"
if err := p.Start(nil, nil, sandbox); err == nil || err.Error() != want {
t.Errorf("Start() error = %v, wantErr %q",
err, errors.New(want))
return
}
})
t.Run("unsealed wait of "+id, func(t *testing.T) { t.Run("unsealed wait of "+id, func(t *testing.T) {
wantErr := "proxy not started" wantErr := "proxy not started"
if err := p.Wait(); err == nil || err.Error() != wantErr { if err := p.Wait(); err == nil || err.Error() != wantErr {

View File

@ -6,8 +6,8 @@ import (
"io" "io"
"sync" "sync"
"git.ophivana.moe/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.ophivana.moe/cat/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.
@ -28,21 +28,6 @@ type Proxy struct {
lock sync.RWMutex lock sync.RWMutex
} }
func (p *Proxy) Session() [2]string {
return p.session
}
func (p *Proxy) System() [2]string {
return p.system
}
func (p *Proxy) Sealed() bool {
p.lock.RLock()
defer p.lock.RUnlock()
return p.seal != nil
}
var ( var (
ErrConfig = errors.New("no configuration to seal") ErrConfig = errors.New("no configuration to seal")
) )

View File

@ -9,9 +9,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.ophivana.moe/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.ophivana.moe/cat/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/ldd" "git.ophivana.moe/cat/fortify/ldd"
) )
// Start launches the D-Bus proxy and sets up the Wait method. // Start launches the D-Bus proxy and sets up the Wait method.
@ -79,15 +79,20 @@ func (p *Proxy) Start(ready chan error, output io.Writer, sandbox bool) error {
} }
} }
} }
bindTargetDedup := make([][2]string, 0, len(bindTarget))
for k := range bindTarget { for k := range bindTarget {
bc.Bind(k, k, false, true) bindTargetDedup = append(bindTargetDedup, [2]string{k, k})
} }
bc.Bind = append(bc.Bind, bindTargetDedup...)
roBindTarget := make(map[string]struct{}, 2+1+len(proxyDeps)) roBindTarget := make(map[string]struct{}, 2+1+len(proxyDeps))
// xdb-dbus-proxy bin and dependencies // xdb-dbus-proxy bin and dependencies
roBindTarget[path.Dir(toolPath)] = struct{}{} roBindTarget[path.Dir(toolPath)] = struct{}{}
for _, ent := range proxyDeps { for _, ent := range proxyDeps {
if ent == nil {
continue
}
if path.IsAbs(ent.Path) { if path.IsAbs(ent.Path) {
roBindTarget[path.Dir(ent.Path)] = struct{}{} roBindTarget[path.Dir(ent.Path)] = struct{}{}
} }
@ -101,9 +106,11 @@ func (p *Proxy) Start(ready chan error, output io.Writer, sandbox bool) error {
} }
} }
roBindTargetDedup := make([][2]string, 0, len(roBindTarget))
for k := range roBindTarget { for k := range roBindTarget {
bc.Bind(k, k) roBindTargetDedup = append(roBindTargetDedup, [2]string{k, k})
} }
bc.ROBind = append(bc.ROBind, roBindTargetDedup...)
h = helper.MustNewBwrap(bc, p.seal, toolPath, argF) h = helper.MustNewBwrap(bc, p.seal, toolPath, argF)
cmd = h.Unwrap() cmd = h.Unwrap()

View File

@ -3,7 +3,7 @@ package dbus_test
import ( import (
"sync" "sync"
"git.ophivana.moe/security/fortify/dbus" "git.ophivana.moe/cat/fortify/dbus"
) )
var samples = []dbusTestCase{ var samples = []dbusTestCase{
@ -175,6 +175,7 @@ func testCaseGenerate() {
// inject nulls // inject nulls
fi := &testCasesV[len(samples)+i] fi := &testCasesV[len(samples)+i]
fi.wantErr = true fi.wantErr = true
fi.c = &*fi.c
injectNulls(&fi.c.See) injectNulls(&fi.c.See)
injectNulls(&fi.c.Talk) injectNulls(&fi.c.Talk)

View File

@ -3,7 +3,7 @@ package dbus_test
import ( import (
"testing" "testing"
"git.ophivana.moe/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
) )
func TestHelperChildStub(t *testing.T) { func TestHelperChildStub(t *testing.T) {

View File

@ -1,55 +0,0 @@
package main
import (
"errors"
"git.ophivana.moe/security/fortify/internal/app"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
func logWaitError(err error) {
var e *fmsg.BaseError
if !fmsg.AsBaseError(err, &e) {
fmsg.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
fmsg.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
fmsg.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
fmsg.Println("invalid error type returned by revert:", ei)
} else {
// print inner *app.BaseError message
fmsg.Print(eb.Message())
}
}
}
}
}
}
func logBaseError(err error, message string) {
var e *fmsg.BaseError
if fmsg.AsBaseError(err, &e) {
fmsg.Print(e.Message())
} else {
fmsg.Println(message, err)
}
}

56
flag.go Normal file
View File

@ -0,0 +1,56 @@
package main
import (
"flag"
"git.ophivana.moe/cat/fortify/internal"
)
var (
userName string
confPath string
dbusConfigSession string
dbusConfigSystem string
dbusVerbose bool
dbusID string
mpris bool
mustWayland bool
mustX bool
mustDBus bool
mustPulse bool
flagVerbose bool
printVersion bool
launchMethodText string
)
func init() {
flag.StringVar(&userName, "u", "chronos", "Passwd name of user to run as")
flag.StringVar(&confPath, "c", "nil", "Path to full app configuration, or \"nil\" to configure from flags")
flag.StringVar(&dbusConfigSession, "dbus-config", "builtin", "Path to D-Bus proxy config file, or \"builtin\" for defaults")
flag.StringVar(&dbusConfigSystem, "dbus-system", "nil", "Path to system D-Bus proxy config file, or \"nil\" to disable")
flag.BoolVar(&dbusVerbose, "dbus-log", false, "Enable logging in the D-Bus proxy")
flag.StringVar(&dbusID, "dbus-id", "", "D-Bus ID of application, leave empty to disable own paths, has no effect if custom config is available")
flag.BoolVar(&mpris, "mpris", false, "Allow owning MPRIS D-Bus path, has no effect if custom config is available")
flag.BoolVar(&mustWayland, "wayland", false, "Share Wayland socket")
flag.BoolVar(&mustX, "X", false, "Share X11 socket and allow connection")
flag.BoolVar(&mustDBus, "dbus", false, "Proxy D-Bus connection")
flag.BoolVar(&mustPulse, "pulse", false, "Share PulseAudio socket and cookie")
flag.BoolVar(&flagVerbose, "v", false, "Verbose output")
flag.BoolVar(&printVersion, "V", false, "Print version")
}
func init() {
methodHelpString := "Method of launching the child process, can be one of \"sudo\", \"bubblewrap\""
if internal.SdBootedV {
methodHelpString += ", \"systemd\""
}
flag.StringVar(&launchMethodText, "method", "sudo", methodHelpString)
}

View File

@ -34,10 +34,6 @@
devShells = forAllSystems (system: { devShells = forAllSystems (system: {
default = nixpkgsFor.${system}.mkShell { default = nixpkgsFor.${system}.mkShell {
buildInputs = with nixpkgsFor.${system}; self.packages.${system}.fortify.buildInputs;
};
withPackage = nixpkgsFor.${system}.mkShell {
buildInputs = buildInputs =
with nixpkgsFor.${system}; with nixpkgsFor.${system};
self.packages.${system}.fortify.buildInputs ++ [ self.packages.${system}.fortify ]; self.packages.${system}.fortify.buildInputs ++ [ self.packages.${system}.fortify ];

2
go.mod
View File

@ -1,3 +1,3 @@
module git.ophivana.moe/security/fortify module git.ophivana.moe/cat/fortify
go 1.22 go 1.22

View File

@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.ophivana.moe/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
) )
func Test_argsFD_String(t *testing.T) { func Test_argsFD_String(t *testing.T) {

View File

@ -7,7 +7,7 @@ import (
"strconv" "strconv"
"sync" "sync"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.ophivana.moe/cat/fortify/helper/bwrap"
) )
// BubblewrapName is the file name or path to bubblewrap. // BubblewrapName is the file name or path to bubblewrap.
@ -19,6 +19,8 @@ type bubblewrap struct {
// bwrap pipes // bwrap pipes
p *pipes p *pipes
// sealed bwrap config
config *bwrap.Config
// returns an array of arguments passed directly // returns an array of arguments passed directly
// to the child process spawned by bwrap // to the child process spawned by bwrap
argF func(argsFD, statFD int) []string argF func(argsFD, statFD int) []string

View File

@ -1,77 +0,0 @@
package bwrap
import "encoding/gob"
type Builder interface {
Len() int
Append(args *[]string)
}
type FSBuilder interface {
Path() string
Builder
}
func init() {
gob.Register(new(pairF))
gob.Register(new(stringF))
}
type pairF [3]string
func (p *pairF) Path() string {
return p[2]
}
func (p *pairF) Len() int {
return len(p) // compiler replaces this with 3
}
func (p *pairF) Append(args *[]string) {
*args = append(*args, p[0], p[1], p[2])
}
type stringF [2]string
func (s stringF) Path() string {
return s[1]
}
func (s stringF) Len() int {
return len(s) // compiler replaces this with 2
}
func (s stringF) Append(args *[]string) {
*args = append(*args, s[0], s[1])
}
// Args returns a slice of bwrap args corresponding to c.
func (c *Config) Args() (args []string) {
builders := []Builder{
c.boolArgs(),
c.intArgs(),
c.stringArgs(),
c.pairArgs(),
}
// copy FSBuilder slice to builder slice
fb := make([]Builder, len(c.Filesystem)+1)
for i, f := range c.Filesystem {
fb[i] = f
}
fb[len(fb)-1] = c.Chmod
builders = append(builders, fb...)
// accumulate arg count
argc := 0
for _, b := range builders {
argc += b.Len()
}
args = make([]string, 0, argc)
for _, b := range builders {
b.Append(&args)
}
return
}

View File

@ -1,13 +0,0 @@
package bwrap
const (
Tmpfs = iota
Dir
Symlink
)
var awkwardArgs = [...]string{
Tmpfs: "--tmpfs",
Dir: "--dir",
Symlink: "--symlink",
}

View File

@ -1,81 +0,0 @@
package bwrap
const (
UnshareAll = 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, boolArgs[i]...)
}
}
}

View File

@ -1,47 +0,0 @@
package bwrap
import "strconv"
const (
UID = iota
GID
Perms
Size
)
var intArgs = [...]string{
UID: "--uid",
GID: "--gid",
Perms: "--perms",
Size: "--size",
}
func (c *Config) intArgs() Builder {
// Arg types:
// Perms
// are handled by the sequential 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, intArgs[i], strconv.Itoa(*v))
}
}
}

View File

@ -1,73 +0,0 @@
package bwrap
import (
"slices"
)
const (
SetEnv = iota
Bind
BindTry
DevBind
DevBindTry
ROBind
ROBindTry
Chmod
)
var pairArgs = [...]string{
SetEnv: "--setenv",
Bind: "--bind",
BindTry: "--bind-try",
DevBind: "--dev-bind",
DevBindTry: "--dev-bind-try",
ROBind: "--ro-bind",
ROBindTry: "--ro-bind-try",
Chmod: "--chmod",
}
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]}
}
// Arg types:
// Bind
// BindTry
// DevBind
// DevBindTry
// ROBind
// ROBindTry
// Chmod
// are handled by the sequential builder
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, pairArgs[i], v[0], v[1])
}
}
}

View File

@ -1,65 +0,0 @@
package bwrap
const (
Hostname = iota
Chdir
UnsetEnv
LockFile
RemountRO
Procfs
DevTmpfs
Mqueue
)
var stringArgs = [...]string{
Hostname: "--hostname",
Chdir: "--chdir",
UnsetEnv: "--unsetenv",
LockFile: "--lock-file",
RemountRO: "--remount-ro",
Procfs: "--proc",
DevTmpfs: "--dev",
Mqueue: "--mqueue",
}
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}
}
// Arg types:
// RemountRO
// Procfs
// DevTmpfs
// Mqueue
// are handled by the sequential builder
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, stringArgs[i], v)
}
}
}

View File

@ -0,0 +1,64 @@
package bwrap
const (
UnshareAll = iota
UnshareUser
UnshareIPC
UnsharePID
UnshareNet
UnshareUTS
UnshareCGroup
ShareNet
UserNS
Clearenv
NewSession
DieWithParent
AsInit
boolC
)
var boolArgs = func() (b [boolC][]string) {
b[UnshareAll] = []string{"--unshare-all", "--unshare-user"}
b[UnshareUser] = []string{"--unshare-user"}
b[UnshareIPC] = []string{"--unshare-ipc"}
b[UnsharePID] = []string{"--unshare-pid"}
b[UnshareNet] = []string{"--unshare-net"}
b[UnshareUTS] = []string{"--unshare-uts"}
b[UnshareCGroup] = []string{"--unshare-cgroup"}
b[ShareNet] = []string{"--share-net"}
b[UserNS] = []string{"--disable-userns", "--assert-userns-disabled"}
b[Clearenv] = []string{"--clearenv"}
b[NewSession] = []string{"--new-session"}
b[DieWithParent] = []string{"--die-with-parent"}
b[AsInit] = []string{"--as-pid-1"}
return
}()
func (c *Config) boolArgs() (b [boolC]bool) {
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
}
b[UserNS] = !c.UserNS
b[Clearenv] = c.Clearenv
b[NewSession] = c.NewSession
b[DieWithParent] = c.DieWithParent
b[AsInit] = c.AsInit
return
}

View File

@ -1,14 +1,63 @@
package bwrap package bwrap
import ( import (
"encoding/gob"
"os" "os"
"strconv" "strconv"
) )
func init() { func (c *Config) Args() (args []string) {
gob.Register(new(PermConfig[SymlinkConfig])) b := c.boolArgs()
gob.Register(new(PermConfig[*TmpfsConfig])) n := c.intArgs()
s := c.stringArgs()
p := c.pairArgs()
g := c.interfaceArgs()
argc := 0
for i, arg := range b {
if arg {
argc += len(boolArgs[i])
}
}
for _, arg := range n {
if arg != nil {
argc += 2
}
}
for _, arg := range s {
argc += len(arg) * 2
}
for _, arg := range p {
argc += len(arg) * 3
}
args = make([]string, 0, argc)
for i, arg := range b {
if arg {
args = append(args, boolArgs[i]...)
}
}
for i, arg := range n {
if arg != nil {
args = append(args, intArgs[i], strconv.Itoa(*arg))
}
}
for i, arg := range s {
for _, v := range arg {
args = append(args, stringArgs[i], v)
}
}
for i, arg := range p {
for _, v := range arg {
args = append(args, pairArgs[i], v[0], v[1])
}
}
for i, arg := range g {
for _, v := range arg {
args = append(args, v.Value(interfaceArgs[i])...)
}
}
return
} }
type Config struct { type Config struct {
@ -51,12 +100,53 @@ type Config struct {
// (--lock-file DEST) // (--lock-file DEST)
LockFile []string `json:"lock_file,omitempty"` LockFile []string `json:"lock_file,omitempty"`
// ordered filesystem args // bind mount host path on sandbox
Filesystem []FSBuilder // (--bind SRC DEST)
Bind [][2]string `json:"bind,omitempty"`
// equal to Bind but ignores non-existent host path
// (--bind-try SRC DEST)
BindTry [][2]string `json:"bind_try,omitempty"`
// bind mount host path on sandbox, allowing device access
// (--dev-bind SRC DEST)
DevBind [][2]string `json:"dev_bind,omitempty"`
// equal to DevBind but ignores non-existent host path
// (--dev-bind-try SRC DEST)
DevBindTry [][2]string `json:"dev_bind_try,omitempty"`
// bind mount host path readonly on sandbox
// (--ro-bind SRC DEST)
ROBind [][2]string `json:"ro_bind,omitempty"`
// equal to ROBind but ignores non-existent host path
// (--ro-bind-try SRC DEST)
ROBindTry [][2]string `json:"ro_bind_try,omitempty"`
// remount path as readonly; does not recursively remount
// (--remount-ro DEST)
RemountRO []string `json:"remount_ro,omitempty"`
// mount new procfs in sandbox
// (--proc DEST)
Procfs []PermConfig[string] `json:"proc,omitempty"`
// mount new dev in sandbox
// (--dev DEST)
DevTmpfs []PermConfig[string] `json:"dev,omitempty"`
// mount new tmpfs in sandbox
// (--tmpfs DEST)
Tmpfs []PermConfig[TmpfsConfig] `json:"tmpfs,omitempty"`
// mount new mqueue in sandbox
// (--mqueue DEST)
Mqueue []PermConfig[string] `json:"mqueue,omitempty"`
// create dir in sandbox
// (--dir DEST)
Dir []PermConfig[string] `json:"dir,omitempty"`
// create symlink within sandbox
// (--symlink SRC DEST)
Symlink []PermConfig[[2]string] `json:"symlink,omitempty"`
// change permissions (must already exist) // change permissions (must already exist)
// (--chmod OCTAL PATH) // (--chmod OCTAL PATH)
Chmod ChmodConfig `json:"chmod,omitempty"` Chmod map[string]os.FileMode `json:"chmod,omitempty"`
// create a new terminal session // create a new terminal session
// (--new-session) // (--new-session)
@ -113,34 +203,6 @@ type UnshareConfig struct {
CGroup bool `json:"cgroup"` CGroup bool `json:"cgroup"`
} }
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, intArgs[Perms], strconv.FormatInt(int64(*p.Mode), 8))
}
p.Inner.Append(args)
}
type TmpfsConfig struct { type TmpfsConfig struct {
// set size of tmpfs // set size of tmpfs
// (--size BYTES) // (--size BYTES)
@ -150,47 +212,54 @@ type TmpfsConfig struct {
Dir string `json:"dir"` Dir string `json:"dir"`
} }
func (t *TmpfsConfig) Path() string { type argOf interface {
return t.Dir Value(arg string) (args []string)
} }
func (t *TmpfsConfig) Len() int { func copyToArgOfSlice[T [2]string | string | TmpfsConfig](src []PermConfig[T]) (dst []argOf) {
if t.Size > 0 { dst = make([]argOf, len(src))
return 4 for i, arg := range src {
dst[i] = arg
}
return
}
type PermConfig[T [2]string | string | TmpfsConfig] struct {
// set permissions of next argument
// (--perms OCTAL)
Mode *os.FileMode `json:"mode,omitempty"`
// path to get the new permission
// (--bind-data, --file, etc.)
Path T
}
func (p PermConfig[T]) Value(arg string) (args []string) {
// max possible size
if p.Mode != nil {
args = make([]string, 0, 6)
args = append(args, "--perms", strconv.Itoa(int(*p.Mode)))
} else { } else {
return 2 args = make([]string, 0, 4)
}
} }
func (t *TmpfsConfig) Append(args *[]string) { switch v := any(p.Path).(type) {
if t.Size > 0 { case string:
*args = append(*args, intArgs[Size], strconv.Itoa(t.Size)) args = append(args, arg, v)
} return
*args = append(*args, awkwardArgs[Tmpfs], t.Dir) case [2]string:
args = append(args, arg, v[0], v[1])
return
case TmpfsConfig:
if arg != "--tmpfs" {
panic("unreachable")
} }
type SymlinkConfig [2]string if v.Size > 0 {
args = append(args, "--size", strconv.Itoa(v.Size))
func (s SymlinkConfig) Path() string {
return s[1]
} }
args = append(args, arg, v.Dir)
func (s SymlinkConfig) Len() int { return
return 3 default:
} panic("unreachable")
func (s SymlinkConfig) Append(args *[]string) {
*args = append(*args, awkwardArgs[Symlink], 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, pairArgs[Chmod], strconv.FormatInt(int64(mode), 8), path)
} }
} }

View File

@ -0,0 +1,22 @@
package bwrap
const (
UID = iota
GID
intC
)
var intArgs = func() (n [intC]string) {
n[UID] = "--uid"
n[GID] = "--gid"
return
}()
func (c *Config) intArgs() (n [intC]*int) {
n[UID] = c.UID
n[GID] = c.GID
return
}

View File

@ -0,0 +1,34 @@
package bwrap
const (
Procfs = iota
DevTmpfs
Tmpfs
Mqueue
Dir
Symlink
interfaceC
)
var interfaceArgs = func() (g [interfaceC]string) {
g[Procfs] = "--proc"
g[DevTmpfs] = "--dev"
g[Tmpfs] = "--tmpfs"
g[Mqueue] = "--mqueue"
g[Dir] = "--dir"
g[Symlink] = "--symlink"
return
}()
func (c *Config) interfaceArgs() (g [interfaceC][]argOf) {
g[Procfs] = copyToArgOfSlice(c.Procfs)
g[DevTmpfs] = copyToArgOfSlice(c.DevTmpfs)
g[Tmpfs] = copyToArgOfSlice(c.Tmpfs)
g[Mqueue] = copyToArgOfSlice(c.Mqueue)
g[Dir] = copyToArgOfSlice(c.Dir)
g[Symlink] = copyToArgOfSlice(c.Symlink)
return
}

View File

@ -0,0 +1,54 @@
package bwrap
import "strconv"
const (
SetEnv = iota
Bind
BindTry
DevBind
DevBindTry
ROBind
ROBindTry
Chmod
pairC
)
var pairArgs = func() (n [pairC]string) {
n[SetEnv] = "--setenv"
n[Bind] = "--bind"
n[BindTry] = "--bind-try"
n[DevBind] = "--dev-bind"
n[DevBindTry] = "--dev-bind-try"
n[ROBind] = "--ro-bind"
n[ROBindTry] = "--ro-bind-try"
n[Chmod] = "--chmod"
return
}()
func (c *Config) pairArgs() (n [pairC][][2]string) {
n[SetEnv] = make([][2]string, 0, len(c.SetEnv))
for k, v := range c.SetEnv {
n[SetEnv] = append(n[SetEnv], [2]string{k, v})
}
n[Bind] = c.Bind
n[BindTry] = c.BindTry
n[DevBind] = c.DevBind
n[DevBindTry] = c.DevBindTry
n[ROBind] = c.ROBind
n[ROBindTry] = c.ROBindTry
n[Chmod] = make([][2]string, 0, len(c.Chmod))
for path, octal := range c.Chmod {
n[Chmod] = append(n[Chmod], [2]string{strconv.Itoa(int(octal)), path})
}
return
}

View File

@ -1,138 +0,0 @@
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{pairArgs[DevBindTry], src, dest})
} else {
c.Filesystem = append(c.Filesystem, &pairF{pairArgs[DevBind], src, dest})
}
return c
} else if write {
if try {
c.Filesystem = append(c.Filesystem, &pairF{pairArgs[BindTry], src, dest})
} else {
c.Filesystem = append(c.Filesystem, &pairF{pairArgs[Bind], src, dest})
}
return c
} else {
if try {
c.Filesystem = append(c.Filesystem, &pairF{pairArgs[ROBindTry], src, dest})
} else {
c.Filesystem = append(c.Filesystem, &pairF{pairArgs[ROBind], src, 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{stringArgs[RemountRO], dest})
return c
}
// Procfs mount new procfs in sandbox
// (--proc DEST)
func (c *Config) Procfs(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{stringArgs[Procfs], dest})
return c
}
// DevTmpfs mount new dev in sandbox
// (--dev DEST)
func (c *Config) DevTmpfs(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{stringArgs[DevTmpfs], 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
}
// Mqueue mount new mqueue in sandbox
// (--mqueue DEST)
func (c *Config) Mqueue(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{stringArgs[Mqueue], dest})
return c
}
// Dir create dir in sandbox
// (--dir DEST)
func (c *Config) Dir(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{stringArgs[Dir], dest})
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
}

View File

@ -0,0 +1,35 @@
package bwrap
const (
Hostname = iota
Chdir
UnsetEnv
LockFile
RemountRO
stringC
)
var stringArgs = func() (n [stringC]string) {
n[Hostname] = "--hostname"
n[Chdir] = "--chdir"
n[UnsetEnv] = "--unsetenv"
n[LockFile] = "--lock-file"
n[RemountRO] = "--remount-ro"
return
}()
func (c *Config) stringArgs() (n [stringC][]string) {
if c.Hostname != "" {
n[Hostname] = []string{c.Hostname}
}
if c.Chdir != "" {
n[Chdir] = []string{c.Chdir}
}
n[UnsetEnv] = c.UnsetEnv
n[LockFile] = c.LockFile
n[RemountRO] = c.RemountRO
return
}

View File

@ -13,49 +13,47 @@ func TestConfig_Args(t *testing.T) {
}{ }{
{ {
name: "xdg-dbus-proxy constraint sample", name: "xdg-dbus-proxy constraint sample",
conf: (&Config{ conf: &Config{
Unshare: nil, Unshare: nil,
UserNS: false, UserNS: false,
Clearenv: true, Clearenv: true,
Symlink: []PermConfig[[2]string]{
{Path: [2]string{"usr/bin", "/bin"}},
{Path: [2]string{"var/home", "/home"}},
{Path: [2]string{"usr/lib", "/lib"}},
{Path: [2]string{"usr/lib64", "/lib64"}},
{Path: [2]string{"run/media", "/media"}},
{Path: [2]string{"var/mnt", "/mnt"}},
{Path: [2]string{"var/opt", "/opt"}},
{Path: [2]string{"sysroot/ostree", "/ostree"}},
{Path: [2]string{"var/roothome", "/root"}},
{Path: [2]string{"usr/sbin", "/sbin"}},
{Path: [2]string{"var/srv", "/srv"}},
},
Bind: [][2]string{
{"/run", "/run"},
{"/tmp", "/tmp"},
{"/var", "/var"},
{"/run/user/1971/.dbus-proxy/", "/run/user/1971/.dbus-proxy/"},
},
ROBind: [][2]string{
{"/boot", "/boot"},
{"/dev", "/dev"},
{"/proc", "/proc"},
{"/sys", "/sys"},
{"/sysroot", "/sysroot"},
{"/usr", "/usr"},
{"/etc", "/etc"},
},
DieWithParent: 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"),
want: []string{ want: []string{
"--unshare-all", "--unshare-user", "--unshare-all",
"--disable-userns", "--assert-userns-disabled", "--unshare-user",
"--clearenv", "--die-with-parent", "--disable-userns",
"--symlink", "usr/bin", "/bin", "--assert-userns-disabled",
"--symlink", "var/home", "/home", "--clearenv",
"--symlink", "usr/lib", "/lib", "--die-with-parent",
"--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", "/run", "/run",
"--bind", "/tmp", "/tmp", "--bind", "/tmp", "/tmp",
"--bind", "/var", "/var", "--bind", "/var", "/var",
@ -67,149 +65,17 @@ func TestConfig_Args(t *testing.T) {
"--ro-bind", "/sysroot", "/sysroot", "--ro-bind", "/sysroot", "/sysroot",
"--ro-bind", "/usr", "/usr", "--ro-bind", "/usr", "/usr",
"--ro-bind", "/etc", "/etc", "--ro-bind", "/etc", "/etc",
}, "--symlink", "usr/bin", "/bin",
}, "--symlink", "var/home", "/home",
{ "--symlink", "usr/lib", "/lib",
name: "fortify permissive default nixos", "--symlink", "usr/lib64", "/lib64",
conf: (&Config{ "--symlink", "run/media", "/media",
Unshare: nil, "--symlink", "var/mnt", "/mnt",
Net: true, "--symlink", "var/opt", "/opt",
UserNS: true, "--symlink", "sysroot/ostree", "/ostree",
Clearenv: true, "--symlink", "var/roothome", "/root",
SetEnv: map[string]string{ "--symlink", "usr/sbin", "/sbin",
"HOME": "/home/chronos", "--symlink", "var/srv", "/srv"},
"TERM": "xterm-256color",
"FORTIFY_INIT": "3",
"XDG_RUNTIME_DIR": "/run/user/150",
"XDG_SESSION_CLASS": "user",
"XDG_SESSION_TYPE": "tty",
"SHELL": "/run/current-system/sw/bin/zsh",
"USER": "chronos",
},
DieWithParent: true,
AsInit: true,
}).SetUID(65534).SetGID(65534).
Procfs("/proc").DevTmpfs("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", false, true).
Bind("/boot", "/boot", false, true).
Bind("/etc", "/etc", 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("/srv", "/srv", false, true).
Bind("/sys", "/sys", false, true).
Bind("/usr", "/usr", false, true).
Bind("/var", "/var", false, true).
Bind("/run/NetworkManager", "/run/NetworkManager", false, true).
Bind("/run/agetty.reload", "/run/agetty.reload", false, true).
Bind("/run/binfmt", "/run/binfmt", false, true).
Bind("/run/booted-system", "/run/booted-system", false, true).
Bind("/run/credentials", "/run/credentials", false, true).
Bind("/run/cryptsetup", "/run/cryptsetup", false, true).
Bind("/run/current-system", "/run/current-system", false, true).
Bind("/run/host", "/run/host", false, true).
Bind("/run/keys", "/run/keys", false, true).
Bind("/run/libvirt", "/run/libvirt", false, true).
Bind("/run/libvirtd.pid", "/run/libvirtd.pid", false, true).
Bind("/run/lock", "/run/lock", false, true).
Bind("/run/log", "/run/log", false, true).
Bind("/run/lvm", "/run/lvm", false, true).
Bind("/run/mount", "/run/mount", false, true).
Bind("/run/nginx", "/run/nginx", false, true).
Bind("/run/nscd", "/run/nscd", false, true).
Bind("/run/opengl-driver", "/run/opengl-driver", false, true).
Bind("/run/pppd", "/run/pppd", false, true).
Bind("/run/resolvconf", "/run/resolvconf", false, true).
Bind("/run/sddm", "/run/sddm", false, true).
Bind("/run/syncoid", "/run/syncoid", false, true).
Bind("/run/systemd", "/run/systemd", false, true).
Bind("/run/tmpfiles.d", "/run/tmpfiles.d", false, true).
Bind("/run/udev", "/run/udev", false, true).
Bind("/run/udisks2", "/run/udisks2", false, true).
Bind("/run/utmp", "/run/utmp", false, true).
Bind("/run/virtlogd.pid", "/run/virtlogd.pid", false, true).
Bind("/run/wrappers", "/run/wrappers", false, true).
Bind("/run/zed.pid", "/run/zed.pid", false, true).
Bind("/run/zed.state", "/run/zed.state", false, true).
Bind("/tmp/fortify.1971/tmpdir/150", "/tmp", false, true).
Tmpfs("/tmp/fortify.1971", 1048576).
Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/150", 8388608).
Bind("/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/passwd", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/passwd").
Bind("/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/group", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/group").
Bind("/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/passwd", "/etc/passwd").
Bind("/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/group", "/etc/group").
Tmpfs("/var/run/nscd", 8192),
want: []string{
"--unshare-all", "--unshare-user", "--share-net",
"--clearenv", "--die-with-parent", "--as-pid-1",
"--uid", "65534",
"--gid", "65534",
"--setenv", "FORTIFY_INIT", "3",
"--setenv", "HOME", "/home/chronos",
"--setenv", "SHELL", "/run/current-system/sw/bin/zsh",
"--setenv", "TERM", "xterm-256color",
"--setenv", "USER", "chronos",
"--setenv", "XDG_RUNTIME_DIR", "/run/user/150",
"--setenv", "XDG_SESSION_CLASS", "user",
"--setenv", "XDG_SESSION_TYPE", "tty",
"--proc", "/proc", "--dev", "/dev",
"--mqueue", "/dev/mqueue",
"--bind", "/bin", "/bin",
"--bind", "/boot", "/boot",
"--bind", "/etc", "/etc",
"--bind", "/home", "/home",
"--bind", "/lib", "/lib",
"--bind", "/lib64", "/lib64",
"--bind", "/nix", "/nix",
"--bind", "/root", "/root",
"--bind", "/srv", "/srv",
"--bind", "/sys", "/sys",
"--bind", "/usr", "/usr",
"--bind", "/var", "/var",
"--bind", "/run/NetworkManager", "/run/NetworkManager",
"--bind", "/run/agetty.reload", "/run/agetty.reload",
"--bind", "/run/binfmt", "/run/binfmt",
"--bind", "/run/booted-system", "/run/booted-system",
"--bind", "/run/credentials", "/run/credentials",
"--bind", "/run/cryptsetup", "/run/cryptsetup",
"--bind", "/run/current-system", "/run/current-system",
"--bind", "/run/host", "/run/host",
"--bind", "/run/keys", "/run/keys",
"--bind", "/run/libvirt", "/run/libvirt",
"--bind", "/run/libvirtd.pid", "/run/libvirtd.pid",
"--bind", "/run/lock", "/run/lock",
"--bind", "/run/log", "/run/log",
"--bind", "/run/lvm", "/run/lvm",
"--bind", "/run/mount", "/run/mount",
"--bind", "/run/nginx", "/run/nginx",
"--bind", "/run/nscd", "/run/nscd",
"--bind", "/run/opengl-driver", "/run/opengl-driver",
"--bind", "/run/pppd", "/run/pppd",
"--bind", "/run/resolvconf", "/run/resolvconf",
"--bind", "/run/sddm", "/run/sddm",
"--bind", "/run/syncoid", "/run/syncoid",
"--bind", "/run/systemd", "/run/systemd",
"--bind", "/run/tmpfiles.d", "/run/tmpfiles.d",
"--bind", "/run/udev", "/run/udev",
"--bind", "/run/udisks2", "/run/udisks2",
"--bind", "/run/utmp", "/run/utmp",
"--bind", "/run/virtlogd.pid", "/run/virtlogd.pid",
"--bind", "/run/wrappers", "/run/wrappers",
"--bind", "/run/zed.pid", "/run/zed.pid",
"--bind", "/run/zed.state", "/run/zed.state",
"--bind", "/tmp/fortify.1971/tmpdir/150", "/tmp",
"--size", "1048576", "--tmpfs", "/tmp/fortify.1971",
"--size", "1048576", "--tmpfs", "/run/user",
"--size", "8388608", "--tmpfs", "/run/user/150",
"--ro-bind", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/passwd", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/passwd",
"--ro-bind", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/group", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/group",
"--ro-bind", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/passwd", "/etc/passwd",
"--ro-bind", "/tmp/fortify.1971/67a97cc824a64ef789f16b20ca6ce311/group", "/etc/group",
"--size", "8192", "--tmpfs", "/var/run/nscd",
},
}, },
} }

View File

@ -7,8 +7,8 @@ import (
"strings" "strings"
"testing" "testing"
"git.ophivana.moe/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.ophivana.moe/cat/fortify/helper/bwrap"
) )
func TestBwrap(t *testing.T) { func TestBwrap(t *testing.T) {

View File

@ -5,7 +5,7 @@ import (
"os" "os"
"testing" "testing"
"git.ophivana.moe/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
) )
func TestDirect(t *testing.T) { func TestDirect(t *testing.T) {

View File

@ -6,7 +6,7 @@ import (
"testing" "testing"
"time" "time"
"git.ophivana.moe/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
) )
var ( var (

View File

@ -10,8 +10,7 @@ import (
"syscall" "syscall"
"testing" "testing"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.ophivana.moe/cat/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/fmsg"
) )
// InternalChildStub is an internal function but exported because it is cross-package; // InternalChildStub is an internal function but exported because it is cross-package;
@ -34,7 +33,7 @@ func InternalChildStub() {
genericStub(argsFD, statFD) genericStub(argsFD, statFD)
} }
fmsg.Exit(0) os.Exit(0)
} }
// InternalReplaceExecCommand is an internal function but exported because it is cross-package; // InternalReplaceExecCommand is an internal function but exported because it is cross-package;

View File

@ -3,7 +3,7 @@ package helper_test
import ( import (
"testing" "testing"
"git.ophivana.moe/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
) )
func TestHelperChildStub(t *testing.T) { func TestHelperChildStub(t *testing.T) {

View File

@ -1,45 +1,29 @@
package app package app
import ( import (
"os/exec"
"sync" "sync"
"git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/shim"
) )
type App interface { type App interface {
// ID returns a copy of App's unique ID.
ID() ID
// Start sets up the system and starts the App.
Start() error
// Wait waits for App's process to exit and reverts system setup.
Wait() (int, error)
// WaitErr returns error returned by the underlying wait syscall.
WaitErr() error
Seal(config *Config) error Seal(config *Config) error
Start() error
Wait() (int, error)
WaitErr() error
String() string String() string
} }
type app struct { type app struct {
// application unique identifier
id *ID
// operating system interface
os internal.System
// shim process manager
shim *shim.Shim
// child process related information // child process related information
seal *appSeal seal *appSeal
// underlying fortified child process
cmd *exec.Cmd
// error returned waiting for process // error returned waiting for process
waitErr error wait error
lock sync.RWMutex lock sync.RWMutex
} }
func (a *app) ID() ID {
return *a.id
}
func (a *app) String() string { func (a *app) String() string {
if a == nil { if a == nil {
return "(invalid fortified app)" return "(invalid fortified app)"
@ -48,24 +32,21 @@ func (a *app) String() string {
a.lock.RLock() a.lock.RLock()
defer a.lock.RUnlock() defer a.lock.RUnlock()
if a.shim != nil { if a.cmd != nil {
return a.shim.String() return a.cmd.String()
} }
if a.seal != nil { if a.seal != nil {
return "(sealed fortified app as uid " + a.seal.sys.user.Uid + ")" return "(sealed fortified app as uid " + a.seal.sys.Uid + ")"
} }
return "(unsealed fortified app)" return "(unsealed fortified app)"
} }
func (a *app) WaitErr() error { func (a *app) WaitErr() error {
return a.waitErr return a.wait
} }
func New(os internal.System) (App, error) { func New() App {
a := new(app) return new(app)
a.id = new(ID)
a.os = os
return a, newAppID(a.id)
} }

View File

@ -1,592 +0,0 @@
package app_test
import (
"fmt"
"io/fs"
"os/user"
"strconv"
"git.ophivana.moe/security/fortify/acl"
"git.ophivana.moe/security/fortify/dbus"
"git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/app"
"git.ophivana.moe/security/fortify/internal/system"
)
var testCasesNixos = []sealTestCase{
{
"nixos permissive defaults no enablements", new(stubNixOS),
&app.Config{
User: "chronos",
Command: make([]string, 0),
Method: "sudo",
},
app.ID{
0x4a, 0x45, 0x0b, 0x65,
0x96, 0xd7, 0xbc, 0x15,
0xbd, 0x01, 0x78, 0x0e,
0xb9, 0xa6, 0x07, 0xac,
},
system.New(150).
Ensure("/tmp/fortify.1971", 0701).
Ephemeral(system.Process, "/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac", 0701).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/150", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/150", acl.Read, acl.Write, acl.Execute).
Ensure("/run/user/1971/fortify", 0700).UpdatePermType(system.User, "/run/user/1971/fortify", acl.Execute).
Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset
Ephemeral(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", acl.Execute).
WriteType(system.Process, "/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac/passwd", "chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n").
WriteType(system.Process, "/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac/group", "fortify:x:65534:\n"),
(&bwrap.Config{
Net: true,
UserNS: true,
Clearenv: true,
SetEnv: map[string]string{
"HOME": "/home/chronos",
"SHELL": "/run/current-system/sw/bin/zsh",
"TERM": "xterm-256color",
"USER": "chronos",
"XDG_RUNTIME_DIR": "/run/user/150",
"XDG_SESSION_CLASS": "user",
"XDG_SESSION_TYPE": "tty"},
Chmod: make(bwrap.ChmodConfig),
DieWithParent: true,
AsInit: true,
}).SetUID(65534).SetGID(65534).
Procfs("/proc").DevTmpfs("/dev").Mqueue("/dev/mqueue").
Tmpfs("/dev/fortify", 4096).
Bind("/bin", "/bin", false, true).
Bind("/boot", "/boot", false, true).
Bind("/etc", "/dev/fortify/etc").
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("/srv", "/srv", false, true).
Bind("/sys", "/sys", false, true).
Bind("/usr", "/usr", false, true).
Bind("/var", "/var", false, true).
Bind("/run/agetty.reload", "/run/agetty.reload", false, true).
Bind("/run/binfmt", "/run/binfmt", false, true).
Bind("/run/booted-system", "/run/booted-system", false, true).
Bind("/run/credentials", "/run/credentials", false, true).
Bind("/run/cryptsetup", "/run/cryptsetup", false, true).
Bind("/run/current-system", "/run/current-system", false, true).
Bind("/run/host", "/run/host", false, true).
Bind("/run/keys", "/run/keys", false, true).
Bind("/run/libvirt", "/run/libvirt", false, true).
Bind("/run/libvirtd.pid", "/run/libvirtd.pid", false, true).
Bind("/run/lock", "/run/lock", false, true).
Bind("/run/log", "/run/log", false, true).
Bind("/run/lvm", "/run/lvm", false, true).
Bind("/run/mount", "/run/mount", false, true).
Bind("/run/NetworkManager", "/run/NetworkManager", false, true).
Bind("/run/nginx", "/run/nginx", false, true).
Bind("/run/nixos", "/run/nixos", false, true).
Bind("/run/nscd", "/run/nscd", false, true).
Bind("/run/opengl-driver", "/run/opengl-driver", false, true).
Bind("/run/pppd", "/run/pppd", false, true).
Bind("/run/resolvconf", "/run/resolvconf", false, true).
Bind("/run/sddm", "/run/sddm", false, true).
Bind("/run/store", "/run/store", false, true).
Bind("/run/syncoid", "/run/syncoid", false, true).
Bind("/run/system", "/run/system", false, true).
Bind("/run/systemd", "/run/systemd", false, true).
Bind("/run/tmpfiles.d", "/run/tmpfiles.d", false, true).
Bind("/run/udev", "/run/udev", false, true).
Bind("/run/udisks2", "/run/udisks2", false, true).
Bind("/run/utmp", "/run/utmp", false, true).
Bind("/run/virtlogd.pid", "/run/virtlogd.pid", false, true).
Bind("/run/wrappers", "/run/wrappers", false, true).
Bind("/run/zed.pid", "/run/zed.pid", false, true).
Bind("/run/zed.state", "/run/zed.state", false, true).
Symlink("/dev/fortify/etc/alsa", "/etc/alsa").
Symlink("/dev/fortify/etc/bashrc", "/etc/bashrc").
Symlink("/dev/fortify/etc/binfmt.d", "/etc/binfmt.d").
Symlink("/dev/fortify/etc/dbus-1", "/etc/dbus-1").
Symlink("/dev/fortify/etc/default", "/etc/default").
Symlink("/dev/fortify/etc/ethertypes", "/etc/ethertypes").
Symlink("/dev/fortify/etc/fonts", "/etc/fonts").
Symlink("/dev/fortify/etc/fstab", "/etc/fstab").
Symlink("/dev/fortify/etc/fuse.conf", "/etc/fuse.conf").
Symlink("/dev/fortify/etc/host.conf", "/etc/host.conf").
Symlink("/dev/fortify/etc/hostid", "/etc/hostid").
Symlink("/dev/fortify/etc/hostname", "/etc/hostname").
Symlink("/dev/fortify/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Symlink("/dev/fortify/etc/hosts", "/etc/hosts").
Symlink("/dev/fortify/etc/inputrc", "/etc/inputrc").
Symlink("/dev/fortify/etc/ipsec.d", "/etc/ipsec.d").
Symlink("/dev/fortify/etc/issue", "/etc/issue").
Symlink("/dev/fortify/etc/kbd", "/etc/kbd").
Symlink("/dev/fortify/etc/libblockdev", "/etc/libblockdev").
Symlink("/dev/fortify/etc/locale.conf", "/etc/locale.conf").
Symlink("/dev/fortify/etc/localtime", "/etc/localtime").
Symlink("/dev/fortify/etc/login.defs", "/etc/login.defs").
Symlink("/dev/fortify/etc/lsb-release", "/etc/lsb-release").
Symlink("/dev/fortify/etc/lvm", "/etc/lvm").
Symlink("/dev/fortify/etc/machine-id", "/etc/machine-id").
Symlink("/dev/fortify/etc/man_db.conf", "/etc/man_db.conf").
Symlink("/dev/fortify/etc/modprobe.d", "/etc/modprobe.d").
Symlink("/dev/fortify/etc/modules-load.d", "/etc/modules-load.d").
Symlink("/proc/mounts", "/etc/mtab").
Symlink("/dev/fortify/etc/nanorc", "/etc/nanorc").
Symlink("/dev/fortify/etc/netgroup", "/etc/netgroup").
Symlink("/dev/fortify/etc/NetworkManager", "/etc/NetworkManager").
Symlink("/dev/fortify/etc/nix", "/etc/nix").
Symlink("/dev/fortify/etc/nixos", "/etc/nixos").
Symlink("/dev/fortify/etc/NIXOS", "/etc/NIXOS").
Symlink("/dev/fortify/etc/nscd.conf", "/etc/nscd.conf").
Symlink("/dev/fortify/etc/nsswitch.conf", "/etc/nsswitch.conf").
Symlink("/dev/fortify/etc/opensnitchd", "/etc/opensnitchd").
Symlink("/dev/fortify/etc/os-release", "/etc/os-release").
Symlink("/dev/fortify/etc/pam", "/etc/pam").
Symlink("/dev/fortify/etc/pam.d", "/etc/pam.d").
Symlink("/dev/fortify/etc/pipewire", "/etc/pipewire").
Symlink("/dev/fortify/etc/pki", "/etc/pki").
Symlink("/dev/fortify/etc/polkit-1", "/etc/polkit-1").
Symlink("/dev/fortify/etc/profile", "/etc/profile").
Symlink("/dev/fortify/etc/protocols", "/etc/protocols").
Symlink("/dev/fortify/etc/qemu", "/etc/qemu").
Symlink("/dev/fortify/etc/resolv.conf", "/etc/resolv.conf").
Symlink("/dev/fortify/etc/resolvconf.conf", "/etc/resolvconf.conf").
Symlink("/dev/fortify/etc/rpc", "/etc/rpc").
Symlink("/dev/fortify/etc/samba", "/etc/samba").
Symlink("/dev/fortify/etc/sddm.conf", "/etc/sddm.conf").
Symlink("/dev/fortify/etc/secureboot", "/etc/secureboot").
Symlink("/dev/fortify/etc/services", "/etc/services").
Symlink("/dev/fortify/etc/set-environment", "/etc/set-environment").
Symlink("/dev/fortify/etc/shadow", "/etc/shadow").
Symlink("/dev/fortify/etc/shells", "/etc/shells").
Symlink("/dev/fortify/etc/ssh", "/etc/ssh").
Symlink("/dev/fortify/etc/ssl", "/etc/ssl").
Symlink("/dev/fortify/etc/static", "/etc/static").
Symlink("/dev/fortify/etc/subgid", "/etc/subgid").
Symlink("/dev/fortify/etc/subuid", "/etc/subuid").
Symlink("/dev/fortify/etc/sudoers", "/etc/sudoers").
Symlink("/dev/fortify/etc/sysctl.d", "/etc/sysctl.d").
Symlink("/dev/fortify/etc/systemd", "/etc/systemd").
Symlink("/dev/fortify/etc/terminfo", "/etc/terminfo").
Symlink("/dev/fortify/etc/tmpfiles.d", "/etc/tmpfiles.d").
Symlink("/dev/fortify/etc/udev", "/etc/udev").
Symlink("/dev/fortify/etc/udisks2", "/etc/udisks2").
Symlink("/dev/fortify/etc/UPower", "/etc/UPower").
Symlink("/dev/fortify/etc/vconsole.conf", "/etc/vconsole.conf").
Symlink("/dev/fortify/etc/X11", "/etc/X11").
Symlink("/dev/fortify/etc/zfs", "/etc/zfs").
Symlink("/dev/fortify/etc/zinputrc", "/etc/zinputrc").
Symlink("/dev/fortify/etc/zoneinfo", "/etc/zoneinfo").
Symlink("/dev/fortify/etc/zprofile", "/etc/zprofile").
Symlink("/dev/fortify/etc/zshenv", "/etc/zshenv").
Symlink("/dev/fortify/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/150", "/tmp", false, true).
Tmpfs("/tmp/fortify.1971", 1048576).
Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/150", 8388608).
Bind("/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac/passwd", "/etc/passwd").
Bind("/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac/group", "/etc/group").
Tmpfs("/var/run/nscd", 8192),
},
{
"nixos permissive defaults chromium", new(stubNixOS),
&app.Config{
ID: "org.chromium.Chromium",
User: "chronos",
Command: []string{"/run/current-system/sw/bin/zsh", "-c", "exec chromium "},
Confinement: app.ConfinementConfig{
SessionBus: &dbus.Config{
Talk: []string{
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{
"org.freedesktop.portal.*": "*",
},
Broadcast: map[string]string{
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*",
},
Filter: true,
},
SystemBus: &dbus.Config{
Talk: []string{
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower",
},
Filter: true,
},
Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(),
},
Method: "systemd",
},
app.ID{
0xeb, 0xf0, 0x83, 0xd1,
0xb1, 0x75, 0x91, 0x17,
0x82, 0xd4, 0x13, 0x36,
0x9b, 0x64, 0xce, 0x7c,
},
system.New(150).
Ensure("/tmp/fortify.1971", 0701).
Ephemeral(system.Process, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c", 0701).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/150", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/150", acl.Read, acl.Write, acl.Execute).
Ensure("/run/user/1971/fortify", 0700).UpdatePermType(system.User, "/run/user/1971/fortify", acl.Execute).
Ensure("/run/user/1971", 0700).UpdatePermType(system.User, "/run/user/1971", acl.Execute). // this is ordered as is because the previous Ensure only calls mkdir if XDG_RUNTIME_DIR is unset
Ephemeral(system.Process, "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c", acl.Execute).
WriteType(system.Process, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/passwd", "chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n").
WriteType(system.Process, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/group", "fortify:x:65534:\n").
Link("/run/user/1971/wayland-0", "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/wayland").
UpdatePermType(system.EWayland, "/run/user/1971/wayland-0", acl.Read, acl.Write, acl.Execute).
Link("/run/user/1971/pulse/native", "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/pulse").
CopyFile("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie").
MustProxyDBus("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", &dbus.Config{
Talk: []string{
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{
"org.freedesktop.portal.*": "*",
},
Broadcast: map[string]string{
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*",
},
Filter: true,
}, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", &dbus.Config{
Talk: []string{
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower",
},
Filter: true,
}).
UpdatePerm("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", acl.Read, acl.Write).
UpdatePerm("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", acl.Read, acl.Write),
(&bwrap.Config{
Net: true,
UserNS: true,
Clearenv: true,
SetEnv: map[string]string{
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/150/bus",
"DBUS_SYSTEM_BUS_ADDRESS": "unix:path=/run/dbus/system_bus_socket",
"HOME": "/home/chronos",
"PULSE_COOKIE": "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/pulse-cookie",
"PULSE_SERVER": "unix:/run/user/150/pulse/native",
"SHELL": "/run/current-system/sw/bin/zsh",
"TERM": "xterm-256color",
"USER": "chronos",
"WAYLAND_DISPLAY": "/run/user/150/wayland-0",
"XDG_RUNTIME_DIR": "/run/user/150",
"XDG_SESSION_CLASS": "user",
"XDG_SESSION_TYPE": "tty",
},
Chmod: make(bwrap.ChmodConfig),
DieWithParent: true,
AsInit: true,
}).SetUID(65534).SetGID(65534).
Procfs("/proc").DevTmpfs("/dev").Mqueue("/dev/mqueue").
Tmpfs("/dev/fortify", 4096).
Bind("/bin", "/bin", false, true).
Bind("/boot", "/boot", false, true).
Bind("/etc", "/dev/fortify/etc").
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("/srv", "/srv", false, true).
Bind("/sys", "/sys", false, true).
Bind("/usr", "/usr", false, true).
Bind("/var", "/var", false, true).
Bind("/run/agetty.reload", "/run/agetty.reload", false, true).
Bind("/run/binfmt", "/run/binfmt", false, true).
Bind("/run/booted-system", "/run/booted-system", false, true).
Bind("/run/credentials", "/run/credentials", false, true).
Bind("/run/cryptsetup", "/run/cryptsetup", false, true).
Bind("/run/current-system", "/run/current-system", false, true).
Bind("/run/host", "/run/host", false, true).
Bind("/run/keys", "/run/keys", false, true).
Bind("/run/libvirt", "/run/libvirt", false, true).
Bind("/run/libvirtd.pid", "/run/libvirtd.pid", false, true).
Bind("/run/lock", "/run/lock", false, true).
Bind("/run/log", "/run/log", false, true).
Bind("/run/lvm", "/run/lvm", false, true).
Bind("/run/mount", "/run/mount", false, true).
Bind("/run/NetworkManager", "/run/NetworkManager", false, true).
Bind("/run/nginx", "/run/nginx", false, true).
Bind("/run/nixos", "/run/nixos", false, true).
Bind("/run/nscd", "/run/nscd", false, true).
Bind("/run/opengl-driver", "/run/opengl-driver", false, true).
Bind("/run/pppd", "/run/pppd", false, true).
Bind("/run/resolvconf", "/run/resolvconf", false, true).
Bind("/run/sddm", "/run/sddm", false, true).
Bind("/run/store", "/run/store", false, true).
Bind("/run/syncoid", "/run/syncoid", false, true).
Bind("/run/system", "/run/system", false, true).
Bind("/run/systemd", "/run/systemd", false, true).
Bind("/run/tmpfiles.d", "/run/tmpfiles.d", false, true).
Bind("/run/udev", "/run/udev", false, true).
Bind("/run/udisks2", "/run/udisks2", false, true).
Bind("/run/utmp", "/run/utmp", false, true).
Bind("/run/virtlogd.pid", "/run/virtlogd.pid", false, true).
Bind("/run/wrappers", "/run/wrappers", false, true).
Bind("/run/zed.pid", "/run/zed.pid", false, true).
Bind("/run/zed.state", "/run/zed.state", false, true).
Bind("/dev/dri", "/dev/dri", true, true, true).
Symlink("/dev/fortify/etc/alsa", "/etc/alsa").
Symlink("/dev/fortify/etc/bashrc", "/etc/bashrc").
Symlink("/dev/fortify/etc/binfmt.d", "/etc/binfmt.d").
Symlink("/dev/fortify/etc/dbus-1", "/etc/dbus-1").
Symlink("/dev/fortify/etc/default", "/etc/default").
Symlink("/dev/fortify/etc/ethertypes", "/etc/ethertypes").
Symlink("/dev/fortify/etc/fonts", "/etc/fonts").
Symlink("/dev/fortify/etc/fstab", "/etc/fstab").
Symlink("/dev/fortify/etc/fuse.conf", "/etc/fuse.conf").
Symlink("/dev/fortify/etc/host.conf", "/etc/host.conf").
Symlink("/dev/fortify/etc/hostid", "/etc/hostid").
Symlink("/dev/fortify/etc/hostname", "/etc/hostname").
Symlink("/dev/fortify/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Symlink("/dev/fortify/etc/hosts", "/etc/hosts").
Symlink("/dev/fortify/etc/inputrc", "/etc/inputrc").
Symlink("/dev/fortify/etc/ipsec.d", "/etc/ipsec.d").
Symlink("/dev/fortify/etc/issue", "/etc/issue").
Symlink("/dev/fortify/etc/kbd", "/etc/kbd").
Symlink("/dev/fortify/etc/libblockdev", "/etc/libblockdev").
Symlink("/dev/fortify/etc/locale.conf", "/etc/locale.conf").
Symlink("/dev/fortify/etc/localtime", "/etc/localtime").
Symlink("/dev/fortify/etc/login.defs", "/etc/login.defs").
Symlink("/dev/fortify/etc/lsb-release", "/etc/lsb-release").
Symlink("/dev/fortify/etc/lvm", "/etc/lvm").
Symlink("/dev/fortify/etc/machine-id", "/etc/machine-id").
Symlink("/dev/fortify/etc/man_db.conf", "/etc/man_db.conf").
Symlink("/dev/fortify/etc/modprobe.d", "/etc/modprobe.d").
Symlink("/dev/fortify/etc/modules-load.d", "/etc/modules-load.d").
Symlink("/proc/mounts", "/etc/mtab").
Symlink("/dev/fortify/etc/nanorc", "/etc/nanorc").
Symlink("/dev/fortify/etc/netgroup", "/etc/netgroup").
Symlink("/dev/fortify/etc/NetworkManager", "/etc/NetworkManager").
Symlink("/dev/fortify/etc/nix", "/etc/nix").
Symlink("/dev/fortify/etc/nixos", "/etc/nixos").
Symlink("/dev/fortify/etc/NIXOS", "/etc/NIXOS").
Symlink("/dev/fortify/etc/nscd.conf", "/etc/nscd.conf").
Symlink("/dev/fortify/etc/nsswitch.conf", "/etc/nsswitch.conf").
Symlink("/dev/fortify/etc/opensnitchd", "/etc/opensnitchd").
Symlink("/dev/fortify/etc/os-release", "/etc/os-release").
Symlink("/dev/fortify/etc/pam", "/etc/pam").
Symlink("/dev/fortify/etc/pam.d", "/etc/pam.d").
Symlink("/dev/fortify/etc/pipewire", "/etc/pipewire").
Symlink("/dev/fortify/etc/pki", "/etc/pki").
Symlink("/dev/fortify/etc/polkit-1", "/etc/polkit-1").
Symlink("/dev/fortify/etc/profile", "/etc/profile").
Symlink("/dev/fortify/etc/protocols", "/etc/protocols").
Symlink("/dev/fortify/etc/qemu", "/etc/qemu").
Symlink("/dev/fortify/etc/resolv.conf", "/etc/resolv.conf").
Symlink("/dev/fortify/etc/resolvconf.conf", "/etc/resolvconf.conf").
Symlink("/dev/fortify/etc/rpc", "/etc/rpc").
Symlink("/dev/fortify/etc/samba", "/etc/samba").
Symlink("/dev/fortify/etc/sddm.conf", "/etc/sddm.conf").
Symlink("/dev/fortify/etc/secureboot", "/etc/secureboot").
Symlink("/dev/fortify/etc/services", "/etc/services").
Symlink("/dev/fortify/etc/set-environment", "/etc/set-environment").
Symlink("/dev/fortify/etc/shadow", "/etc/shadow").
Symlink("/dev/fortify/etc/shells", "/etc/shells").
Symlink("/dev/fortify/etc/ssh", "/etc/ssh").
Symlink("/dev/fortify/etc/ssl", "/etc/ssl").
Symlink("/dev/fortify/etc/static", "/etc/static").
Symlink("/dev/fortify/etc/subgid", "/etc/subgid").
Symlink("/dev/fortify/etc/subuid", "/etc/subuid").
Symlink("/dev/fortify/etc/sudoers", "/etc/sudoers").
Symlink("/dev/fortify/etc/sysctl.d", "/etc/sysctl.d").
Symlink("/dev/fortify/etc/systemd", "/etc/systemd").
Symlink("/dev/fortify/etc/terminfo", "/etc/terminfo").
Symlink("/dev/fortify/etc/tmpfiles.d", "/etc/tmpfiles.d").
Symlink("/dev/fortify/etc/udev", "/etc/udev").
Symlink("/dev/fortify/etc/udisks2", "/etc/udisks2").
Symlink("/dev/fortify/etc/UPower", "/etc/UPower").
Symlink("/dev/fortify/etc/vconsole.conf", "/etc/vconsole.conf").
Symlink("/dev/fortify/etc/X11", "/etc/X11").
Symlink("/dev/fortify/etc/zfs", "/etc/zfs").
Symlink("/dev/fortify/etc/zinputrc", "/etc/zinputrc").
Symlink("/dev/fortify/etc/zoneinfo", "/etc/zoneinfo").
Symlink("/dev/fortify/etc/zprofile", "/etc/zprofile").
Symlink("/dev/fortify/etc/zshenv", "/etc/zshenv").
Symlink("/dev/fortify/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/150", "/tmp", false, true).
Tmpfs("/tmp/fortify.1971", 1048576).
Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/150", 8388608).
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/passwd", "/etc/passwd").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/group", "/etc/group").
Bind("/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/150/wayland-0").
Bind("/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/pulse", "/run/user/150/pulse/native").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/pulse-cookie", "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/pulse-cookie").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", "/run/user/150/bus").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", "/run/dbus/system_bus_socket").
Tmpfs("/var/run/nscd", 8192),
},
}
// fs methods are not implemented using a real FS
// to help better understand filesystem access behaviour
type stubNixOS struct {
lookPathErr map[string]error
usernameErr map[string]error
}
func (s *stubNixOS) Geteuid() int {
return 1971
}
func (s *stubNixOS) LookupEnv(key string) (string, bool) {
switch key {
case "SHELL":
return "/run/current-system/sw/bin/zsh", true
case "TERM":
return "xterm-256color", true
case "WAYLAND_DISPLAY":
return "wayland-0", true
case "PULSE_COOKIE":
return "", false
case "HOME":
return "/home/ophestra", true
case "XDG_CONFIG_HOME":
return "/home/ophestra/xdg/config", true
default:
panic(fmt.Sprintf("attempted to access unexpected environment variable %q", key))
}
}
func (s *stubNixOS) TempDir() string {
return "/tmp"
}
func (s *stubNixOS) LookPath(file string) (string, error) {
if s.lookPathErr != nil {
if err, ok := s.lookPathErr[file]; ok {
return "", err
}
}
switch file {
case "sudo":
return "/run/wrappers/bin/sudo", nil
case "machinectl":
return "/home/ophestra/.nix-profile/bin/machinectl", nil
default:
panic(fmt.Sprintf("attempted to look up unexpected executable %q", file))
}
}
func (s *stubNixOS) Executable() (string, error) {
return "/home/ophestra/.nix-profile/bin/fortify", nil
}
func (s *stubNixOS) Lookup(username string) (*user.User, error) {
if s.usernameErr != nil {
if err, ok := s.usernameErr[username]; ok {
return nil, err
}
}
switch username {
case "chronos":
return &user.User{
Uid: "150",
Gid: "101",
Username: "chronos",
HomeDir: "/home/chronos",
}, nil
default:
return nil, user.UnknownUserError(username)
}
}
func (s *stubNixOS) ReadDir(name string) ([]fs.DirEntry, error) {
switch name {
case "/":
return stubDirEntries("bin", "boot", "dev", "etc", "home", "lib",
"lib64", "nix", "proc", "root", "run", "srv", "sys", "tmp", "usr", "var")
case "/run":
return stubDirEntries("agetty.reload", "binfmt", "booted-system",
"credentials", "cryptsetup", "current-system", "dbus", "host", "keys",
"libvirt", "libvirtd.pid", "lock", "log", "lvm", "mount", "NetworkManager",
"nginx", "nixos", "nscd", "opengl-driver", "pppd", "resolvconf", "sddm",
"store", "syncoid", "system", "systemd", "tmpfiles.d", "udev", "udisks2",
"user", "utmp", "virtlogd.pid", "wrappers", "zed.pid", "zed.state")
case "/etc":
return stubDirEntries("alsa", "bashrc", "binfmt.d", "dbus-1", "default",
"ethertypes", "fonts", "fstab", "fuse.conf", "group", "host.conf", "hostid",
"hostname", "hostname.CHECKSUM", "hosts", "inputrc", "ipsec.d", "issue", "kbd",
"libblockdev", "locale.conf", "localtime", "login.defs", "lsb-release", "lvm",
"machine-id", "man_db.conf", "modprobe.d", "modules-load.d", "mtab", "nanorc",
"netgroup", "NetworkManager", "nix", "nixos", "NIXOS", "nscd.conf", "nsswitch.conf",
"opensnitchd", "os-release", "pam", "pam.d", "passwd", "pipewire", "pki", "polkit-1",
"profile", "protocols", "qemu", "resolv.conf", "resolvconf.conf", "rpc", "samba",
"sddm.conf", "secureboot", "services", "set-environment", "shadow", "shells", "ssh",
"ssl", "static", "subgid", "subuid", "sudoers", "sysctl.d", "systemd", "terminfo",
"tmpfiles.d", "udev", "udisks2", "UPower", "vconsole.conf", "X11", "zfs", "zinputrc",
"zoneinfo", "zprofile", "zshenv", "zshrc")
default:
panic(fmt.Sprintf("attempted to read unexpected directory %q", name))
}
}
func (s *stubNixOS) Stat(name string) (fs.FileInfo, error) {
switch name {
case "/var/run/nscd":
return nil, nil
case "/run/user/1971/pulse":
return nil, nil
case "/run/user/1971/pulse/native":
return stubFileInfoMode(0666), nil
case "/home/ophestra/.pulse-cookie":
return stubFileInfoIsDir(true), nil
case "/home/ophestra/xdg/config/pulse/cookie":
return stubFileInfoIsDir(false), nil
default:
panic(fmt.Sprintf("attempted to stat unexpected path %q", name))
}
}
func (s *stubNixOS) Open(name string) (fs.File, error) {
switch name {
default:
panic(fmt.Sprintf("attempted to open unexpected file %q", name))
}
}
func (s *stubNixOS) Exit(code int) {
panic("called exit on stub with code " + strconv.Itoa(code))
}
func (s *stubNixOS) Paths() internal.Paths {
return internal.Paths{
SharePath: "/tmp/fortify.1971",
RuntimePath: "/run/user/1971",
RunDirPath: "/run/user/1971/fortify",
}
}
func (s *stubNixOS) SdBooted() bool {
return true
}

View File

@ -1,134 +0,0 @@
package app_test
import (
"io/fs"
"reflect"
"testing"
"time"
"git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/app"
"git.ophivana.moe/security/fortify/internal/system"
)
type sealTestCase struct {
name string
os internal.System
config *app.Config
id app.ID
wantSys *system.I
wantBwrap *bwrap.Config
}
func TestApp(t *testing.T) {
testCases := append(testCasesNixos)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
a := app.NewWithID(tc.id, tc.os)
if !t.Run("seal", func(t *testing.T) {
if err := a.Seal(tc.config); err != nil {
t.Errorf("Seal: error = %v", err)
}
}) {
return
}
gotSys, gotBwrap := app.AppSystemBwrap(a)
t.Run("compare sys", func(t *testing.T) {
if !gotSys.Equal(tc.wantSys) {
t.Errorf("Seal: sys = %#v, want %#v",
gotSys, tc.wantSys)
}
})
t.Run("compare bwrap", func(t *testing.T) {
if !reflect.DeepEqual(gotBwrap, tc.wantBwrap) {
t.Errorf("seal: bwrap = %#v, want %#v",
gotBwrap, tc.wantBwrap)
}
})
})
}
}
func stubDirEntries(names ...string) (e []fs.DirEntry, err error) {
e = make([]fs.DirEntry, len(names))
for i, name := range names {
e[i] = stubDirEntryPath(name)
}
return
}
type stubDirEntryPath string
func (p stubDirEntryPath) Name() string {
return string(p)
}
func (p stubDirEntryPath) IsDir() bool {
panic("attempted to call IsDir")
}
func (p stubDirEntryPath) Type() fs.FileMode {
panic("attempted to call Type")
}
func (p stubDirEntryPath) Info() (fs.FileInfo, error) {
panic("attempted to call Info")
}
type stubFileInfoMode fs.FileMode
func (s stubFileInfoMode) Name() string {
panic("attempted to call Name")
}
func (s stubFileInfoMode) Size() int64 {
panic("attempted to call Size")
}
func (s stubFileInfoMode) Mode() fs.FileMode {
return fs.FileMode(s)
}
func (s stubFileInfoMode) ModTime() time.Time {
panic("attempted to call ModTime")
}
func (s stubFileInfoMode) IsDir() bool {
panic("attempted to call IsDir")
}
func (s stubFileInfoMode) Sys() any {
panic("attempted to call Sys")
}
type stubFileInfoIsDir bool
func (s stubFileInfoIsDir) Name() string {
panic("attempted to call Name")
}
func (s stubFileInfoIsDir) Size() int64 {
panic("attempted to call Size")
}
func (s stubFileInfoIsDir) Mode() fs.FileMode {
panic("attempted to call Mode")
}
func (s stubFileInfoIsDir) ModTime() time.Time {
panic("attempted to call ModTime")
}
func (s stubFileInfoIsDir) IsDir() bool {
return bool(s)
}
func (s stubFileInfoIsDir) Sys() any {
panic("attempted to call Sys")
}

View File

@ -1,11 +1,8 @@
package app package app
import ( import (
"os" "git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/security/fortify/dbus"
"git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/system"
) )
// Config is used to seal an *App // Config is used to seal an *App
@ -25,9 +22,6 @@ type Config struct {
// ConfinementConfig defines fortified child's confinement // ConfinementConfig defines fortified child's confinement
type ConfinementConfig struct { type ConfinementConfig struct {
// bwrap sandbox confinement configuration
Sandbox *SandboxConfig `json:"sandbox"`
// reference to a system D-Bus proxy configuration, // reference to a system D-Bus proxy configuration,
// nil value disables system bus proxy // nil value disables system bus proxy
SystemBus *dbus.Config `json:"system_bus,omitempty"` SystemBus *dbus.Config `json:"system_bus,omitempty"`
@ -36,145 +30,5 @@ type ConfinementConfig struct {
SessionBus *dbus.Config `json:"session_bus,omitempty"` SessionBus *dbus.Config `json:"session_bus,omitempty"`
// child capability enablements // child capability enablements
Enablements system.Enablements `json:"enablements"` Enablements state.Enablements `json:"enablements"`
}
// SandboxConfig describes resources made available to the sandbox.
type SandboxConfig struct {
// unix hostname within sandbox
Hostname string `json:"hostname,omitempty"`
// userns availability within sandbox
UserNS bool `json:"userns,omitempty"`
// share net namespace
Net bool `json:"net,omitempty"`
// do not run in new session
NoNewSession bool `json:"no_new_session,omitempty"`
// mediated access to wayland socket
Wayland bool `json:"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"`
// paths to override by mounting tmpfs over them
Override []string `json:"override"`
}
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"`
// exit if unable to share
Must bool `json:"require,omitempty"`
}
// Bwrap returns the address of the corresponding bwrap.Config to s.
// Note that remaining tmpfs entries must be queued by the caller prior to launch.
func (s *SandboxConfig) Bwrap() *bwrap.Config {
if s == nil {
return nil
}
conf := (&bwrap.Config{
Net: s.Net,
UserNS: s.UserNS,
Hostname: s.Hostname,
Clearenv: true,
SetEnv: s.Env,
NewSession: !s.NoNewSession,
DieWithParent: true,
AsInit: true,
// initialise map
Chmod: make(map[string]os.FileMode),
}).
SetUID(65534).SetGID(65534).
Procfs("/proc").DevTmpfs("/dev").Mqueue("/dev/mqueue").
Tmpfs("/dev/fortify", 4*1024)
for _, c := range s.Filesystem {
if c == nil {
continue
}
src := c.Src
dest := c.Dst
if c.Dst == "" {
dest = c.Src
}
conf.Bind(src, dest, !c.Must, c.Write, c.Device)
}
for _, l := range s.Link {
conf.Symlink(l[0], l[1])
}
return conf
}
// Template returns a fully populated instance of Config.
func Template() *Config {
return &Config{
ID: "org.chromium.Chromium",
User: "chronos",
Command: []string{
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland",
},
Method: "sudo",
Confinement: ConfinementConfig{
Sandbox: &SandboxConfig{
Hostname: "localhost",
UserNS: true,
Net: true,
NoNewSession: true,
Wayland: false,
// example API credentials pulled from Google Chrome
// DO NOT USE THESE IN A REAL BROWSER
Env: map[string]string{
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT",
},
Filesystem: []*FilesystemConfig{
{Src: "/nix"},
{Src: "/storage/emulated/0", Write: true, Must: true},
{Src: "/data/user/0", Dst: "/data/data", Write: true, Must: true},
{Src: "/var/tmp", Write: true},
},
Link: [][2]string{{"/dev/fortify/etc", "/etc"}},
Override: []string{"/var/run/nscd"},
},
SystemBus: &dbus.Config{
See: nil,
Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"},
Own: nil,
Call: nil,
Broadcast: nil,
Log: false,
Filter: true,
},
SessionBus: &dbus.Config{
See: nil,
Talk: []string{"org.freedesktop.Notifications", "org.freedesktop.FileManager1", "org.freedesktop.ScreenSaver",
"org.freedesktop.secrets", "org.kde.kwalletd5", "org.kde.kwalletd6", "org.gnome.SessionManager"},
Own: []string{"org.chromium.Chromium.*", "org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*"},
Call: map[string]string{"org.freedesktop.portal.*": "*"},
Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
Log: false,
Filter: true,
},
Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(),
},
}
} }

33
internal/app/copy.go Normal file
View File

@ -0,0 +1,33 @@
package app
import (
"io"
"os"
)
func copyFile(dst, src string) error {
srcD, err := os.Open(src)
if err != nil {
return err
}
defer func() {
if srcD.Close() != nil {
// unreachable
panic("src file closed prematurely")
}
}()
dstD, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer func() {
if dstD.Close() != nil {
// unreachable
panic("dst file closed prematurely")
}
}()
_, err = io.Copy(dstD, srcD)
return err
}

View File

@ -1,4 +1,4 @@
package fmsg package app
import ( import (
"fmt" "fmt"
@ -29,32 +29,11 @@ func (e *BaseError) Message() string {
return e.message return e.message
} }
// WrapError wraps an error with a corresponding message. func wrapError(err error, a ...any) *BaseError {
func WrapError(err error, a ...any) error { return &BaseError{
if err == nil { message: fmt.Sprintln(a...),
return nil baseError: baseError{err},
} }
return wrapError(err, fmt.Sprintln(a...))
}
// WrapErrorSuffix wraps an error with a corresponding message with err at the end of the message.
func WrapErrorSuffix(err error, a ...any) error {
if err == nil {
return nil
}
return wrapError(err, fmt.Sprintln(append(a, err)...))
}
// WrapErrorFunc wraps an error with a corresponding message returned by f.
func WrapErrorFunc(err error, f func(err error) string) error {
if err == nil {
return nil
}
return wrapError(err, f(err))
}
func wrapError(err error, message string) *BaseError {
return &BaseError{message, baseError{err}}
} }
var ( var (

View File

@ -1,19 +0,0 @@
package app
import (
"git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/system"
)
func NewWithID(id ID, os internal.System) App {
a := new(app)
a.id = &id
a.os = os
return a
}
func AppSystemBwrap(a App) (*system.I, *bwrap.Config) {
v := a.(*app)
return v.seal.sys.I, v.seal.sys.bwrap
}

View File

@ -5,13 +5,14 @@ import (
"encoding/hex" "encoding/hex"
) )
type ID [16]byte type appID [16]byte
func (a *ID) String() string { func (a *appID) String() string {
return hex.EncodeToString(a[:]) return hex.EncodeToString(a[:])
} }
func newAppID(id *ID) error { func newAppID() (*appID, error) {
_, err := rand.Read(id[:]) a := &appID{}
return err _, err := rand.Read(a[:])
return a, err
} }

View File

@ -0,0 +1,8 @@
package app
// TODO: launch dbus proxy via bwrap
func (a *app) commandBuilderBwrap() (args []string) {
// TODO: build bwrap command
panic("bwrap")
}

View File

@ -1,36 +1,38 @@
package app package app
import ( import (
"os/exec"
"strings" "strings"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/verbose"
) )
func (a *app) commandBuilderMachineCtl(shimEnv string) (args []string) { func (a *app) commandBuilderMachineCtl() (args []string) {
args = make([]string, 0, 9+len(a.seal.sys.bwrap.SetEnv)) args = make([]string, 0, 9+len(a.seal.env))
// shell --uid=$USER // shell --uid=$USER
args = append(args, "shell", "--uid="+a.seal.sys.user.Username) args = append(args, "shell", "--uid="+a.seal.sys.Username)
// --quiet // --quiet
if !fmsg.Verbose() { if !verbose.Get() {
args = append(args, "--quiet") args = append(args, "--quiet")
} }
// environ // environ
envQ := make([]string, 0, len(a.seal.sys.bwrap.SetEnv)+1) envQ := make([]string, len(a.seal.env)+1)
for k, v := range a.seal.sys.bwrap.SetEnv { for i, e := range a.seal.env {
envQ = append(envQ, "-E"+k+"="+v) envQ[i] = "-E" + e
} }
// add shim payload to environment for shim path // add shim payload to environment for shim path
envQ = append(envQ, "-E"+shimEnv) envQ[len(a.seal.env)] = "-E" + a.shimPayloadEnv()
args = append(args, envQ...) args = append(args, envQ...)
// -- .host // -- .host
args = append(args, "--", ".host") args = append(args, "--", ".host")
// /bin/sh -c // /bin/sh -c
if sh, err := a.os.LookPath("sh"); err != nil { if sh, err := exec.LookPath("sh"); err != nil {
// hardcode /bin/sh path since it exists more often than not // hardcode /bin/sh path since it exists more often than not
args = append(args, "/bin/sh", "-c") args = append(args, "/bin/sh", "-c")
} else { } else {
@ -42,13 +44,21 @@ func (a *app) commandBuilderMachineCtl(shimEnv string) (args []string) {
// apply custom environment variables to activation environment // apply custom environment variables to activation environment
innerCommand.WriteString("dbus-update-activation-environment --systemd") innerCommand.WriteString("dbus-update-activation-environment --systemd")
for k := range a.seal.sys.bwrap.SetEnv { for _, e := range a.seal.env {
innerCommand.WriteString(" " + k) innerCommand.WriteString(" " + strings.SplitN(e, "=", 2)[0])
} }
innerCommand.WriteString("; ") innerCommand.WriteString("; ")
// launch fortify as shim // override message bus address if enabled
innerCommand.WriteString("exec " + a.seal.sys.executable + " shim") if a.seal.et.Has(state.EnableDBus) {
innerCommand.WriteString(dbusSessionBusAddress + "=" + "'" + "unix:path=" + a.seal.sys.dbusAddr[0][1] + "' ")
if a.seal.sys.dbusSystem {
innerCommand.WriteString(dbusSystemBusAddress + "=" + "'" + "unix:path=" + a.seal.sys.dbusAddr[1][1] + "' ")
}
}
// both license and version flags need to be set to activate shim path
innerCommand.WriteString("exec " + a.seal.sys.executable + " -V -license")
// append inner command // append inner command
args = append(args, innerCommand.String()) args = append(args, innerCommand.String())

View File

@ -1,30 +1,33 @@
package app package app
import ( import (
"git.ophivana.moe/security/fortify/internal/fmsg" "os"
"git.ophivana.moe/cat/fortify/internal/verbose"
) )
const ( const (
sudoAskPass = "SUDO_ASKPASS" sudoAskPass = "SUDO_ASKPASS"
) )
func (a *app) commandBuilderSudo(shimEnv string) (args []string) { func (a *app) commandBuilderSudo() (args []string) {
args = make([]string, 0, 8) args = make([]string, 0, 4+len(a.seal.env)+len(a.seal.command))
// -Hiu $USER // -Hiu $USER
args = append(args, "-Hiu", a.seal.sys.user.Username) args = append(args, "-Hiu", a.seal.sys.Username)
// -A? // -A?
if _, ok := a.os.LookupEnv(sudoAskPass); ok { if _, ok := os.LookupEnv(sudoAskPass); ok {
fmsg.VPrintln(sudoAskPass, "set, adding askpass flag") verbose.Printf("%s set, adding askpass flag\n", sudoAskPass)
args = append(args, "-A") args = append(args, "-A")
} }
// shim payload // environ
args = append(args, shimEnv) args = append(args, a.seal.env...)
// -- $@ // -- $@
args = append(args, "--", a.seal.sys.executable, "shim") args = append(args, "--")
args = append(args, a.seal.command...)
return return
} }

View File

@ -2,74 +2,39 @@ package app
import ( import (
"errors" "errors"
"io/fs" "os"
"os/exec"
"os/user" "os/user"
"path"
"strconv" "strconv"
"git.ophivana.moe/security/fortify/dbus" "git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/security/fortify/internal" "git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/security/fortify/internal/shim" "git.ophivana.moe/cat/fortify/internal/verbose"
"git.ophivana.moe/security/fortify/internal/state"
"git.ophivana.moe/security/fortify/internal/system"
) )
const ( const (
LaunchMethodSudo uint8 = iota LaunchMethodSudo uint8 = iota
LaunchMethodBwrap
LaunchMethodMachineCtl LaunchMethodMachineCtl
) )
var method = [...]string{
LaunchMethodSudo: "sudo",
LaunchMethodMachineCtl: "systemd",
}
var ( var (
ErrConfig = errors.New("no configuration to seal") ErrConfig = errors.New("no configuration to seal")
ErrUser = errors.New("unknown user") ErrUser = errors.New("unknown user")
ErrLaunch = errors.New("invalid launch method") ErrLaunch = errors.New("invalid launch method")
ErrSudo = errors.New("sudo not available") ErrSudo = errors.New("sudo not available")
ErrBwrap = errors.New("bwrap not available")
ErrSystemd = errors.New("systemd not available") ErrSystemd = errors.New("systemd not available")
ErrMachineCtl = errors.New("machinectl not available") ErrMachineCtl = errors.New("machinectl not available")
) )
// appSeal seals the application with child-related information type (
type appSeal struct { SealConfigError BaseError
// app unique ID string representation LauncherLookupError BaseError
id string SecurityError BaseError
// wayland mediation, disabled if nil )
wl *shim.Wayland
// freedesktop application ID
fid string
// argv to start process with in the final confined environment
command []string
// persistent process state store
store state.Store
// uint8 representation of launch method sealed from config
launchOption uint8
// process-specific share directory path
share string
// process-specific share directory path local to XDG_RUNTIME_DIR
shareLocal string
// path to launcher program
toolPath string
// pass-through enablement tracking from config
et system.Enablements
// prevents sharing from happening twice
shared bool
// seal system-level component
sys *appSealSys
internal.Paths
// protected by upstream mutex
}
// Seal seals the app launch context // Seal seals the app launch context
func (a *app) Seal(config *Config) error { func (a *app) Seal(config *Config) error {
@ -81,194 +46,104 @@ func (a *app) Seal(config *Config) error {
} }
if config == nil { if config == nil {
return fmsg.WrapError(ErrConfig, return (*SealConfigError)(wrapError(ErrConfig, "attempted to seal app with nil config"))
"attempted to seal app with nil config")
} }
// create seal // create seal
seal := new(appSeal) seal := new(appSeal)
// generate application ID
if id, err := newAppID(); err != nil {
return (*SecurityError)(wrapError(err, "cannot generate application ID:", err))
} else {
seal.id = id
}
// fetch system constants // fetch system constants
seal.Paths = a.os.Paths() seal.SystemConstants = internal.GetSC()
// pass through config values // pass through config values
seal.id = a.id.String()
seal.fid = config.ID seal.fid = config.ID
seal.command = config.Command seal.command = config.Command
// parses launch method text and looks up tool path // parses launch method text and looks up tool path
switch config.Method { switch config.Method {
case method[LaunchMethodSudo]: case "sudo":
seal.launchOption = LaunchMethodSudo seal.launchOption = LaunchMethodSudo
if sudoPath, err := a.os.LookPath("sudo"); err != nil { if sudoPath, err := exec.LookPath("sudo"); err != nil {
return fmsg.WrapError(ErrSudo, return (*LauncherLookupError)(wrapError(ErrSudo, "sudo not found"))
"sudo not found")
} else { } else {
seal.toolPath = sudoPath seal.toolPath = sudoPath
} }
case method[LaunchMethodMachineCtl]: case "bubblewrap":
seal.launchOption = LaunchMethodBwrap
if bwrapPath, err := exec.LookPath("bwrap"); err != nil {
return (*LauncherLookupError)(wrapError(ErrBwrap, "bwrap not found"))
} else {
seal.toolPath = bwrapPath
}
case "systemd":
seal.launchOption = LaunchMethodMachineCtl seal.launchOption = LaunchMethodMachineCtl
if !a.os.SdBooted() { if !internal.SdBootedV {
return fmsg.WrapError(ErrSystemd, return (*LauncherLookupError)(wrapError(ErrSystemd,
"system has not been booted with systemd as init system") "system has not been booted with systemd as init system"))
} }
if machineCtlPath, err := a.os.LookPath("machinectl"); err != nil { if machineCtlPath, err := exec.LookPath("machinectl"); err != nil {
return fmsg.WrapError(ErrMachineCtl, return (*LauncherLookupError)(wrapError(ErrMachineCtl, "machinectl not found"))
"machinectl not found")
} else { } else {
seal.toolPath = machineCtlPath seal.toolPath = machineCtlPath
} }
default: default:
return fmsg.WrapError(ErrLaunch, return (*SealConfigError)(wrapError(ErrLaunch, "invalid launch method"))
"invalid launch method")
} }
// create seal system component // create seal system component
seal.sys = new(appSealSys) seal.sys = new(appSealTx)
// look up fortify executable path // look up fortify executable path
if p, err := a.os.Executable(); err != nil { if p, err := os.Executable(); err != nil {
return fmsg.WrapErrorSuffix(err, "cannot look up fortify executable path:") return (*LauncherLookupError)(wrapError(err, "cannot look up fortify executable path:", err))
} else { } else {
seal.sys.executable = p seal.sys.executable = p
} }
// look up user from system // look up user from system
if u, err := a.os.Lookup(config.User); err != nil { if u, err := user.Lookup(config.User); err != nil {
if errors.As(err, new(user.UnknownUserError)) { if errors.As(err, new(user.UnknownUserError)) {
return fmsg.WrapError(ErrUser, "unknown user", config.User) return (*SealConfigError)(wrapError(ErrUser, "unknown user", config.User))
} else { } else {
// unreachable // unreachable
panic(err) panic(err)
} }
} else { } else {
seal.sys.user = u seal.sys.User = u
seal.sys.runtime = path.Join("/run/user", u.Uid)
}
// map sandbox config to bwrap
if config.Confinement.Sandbox == nil {
fmsg.VPrintln("sandbox configuration not supplied, PROCEED WITH CAUTION")
// permissive defaults
conf := &SandboxConfig{
UserNS: true,
Net: true,
NoNewSession: true,
}
// bind entries in /
if d, err := a.os.ReadDir("/"); err != nil {
return err
} else {
b := make([]*FilesystemConfig, 0, len(d))
for _, ent := range d {
p := "/" + ent.Name()
switch p {
case "/proc":
case "/dev":
case "/run":
case "/tmp":
case "/mnt":
case "/etc":
b = append(b, &FilesystemConfig{Src: p, Dst: "/dev/fortify/etc", Write: false, Must: true})
default:
b = append(b, &FilesystemConfig{Src: p, Write: true, Must: true})
}
}
conf.Filesystem = append(conf.Filesystem, b...)
}
// bind entries in /run
if d, err := a.os.ReadDir("/run"); err != nil {
return err
} else {
b := make([]*FilesystemConfig, 0, len(d))
for _, ent := range d {
name := ent.Name()
switch name {
case "user":
case "dbus":
default:
p := "/run/" + name
b = append(b, &FilesystemConfig{Src: p, Write: true, Must: true})
}
}
conf.Filesystem = append(conf.Filesystem, b...)
}
// hide nscd from sandbox if present
nscd := "/var/run/nscd"
if _, err := a.os.Stat(nscd); !errors.Is(err, fs.ErrNotExist) {
conf.Override = append(conf.Override, nscd)
}
// bind GPU stuff
if config.Confinement.Enablements.Has(system.EX11) || config.Confinement.Enablements.Has(system.EWayland) {
conf.Filesystem = append(conf.Filesystem, &FilesystemConfig{Src: "/dev/dri", Device: true})
}
// link host /etc to prevent passwd/group from being overwritten
if d, err := a.os.ReadDir("/etc"); err != nil {
return err
} else {
b := make([][2]string, 0, len(d))
for _, ent := range d {
name := ent.Name()
switch name {
case "passwd":
case "group":
case "mtab":
b = append(b, [2]string{
"/proc/mounts",
"/etc/" + name,
})
default:
b = append(b, [2]string{
"/dev/fortify/etc/" + name,
"/etc/" + name,
})
}
}
conf.Link = append(conf.Link, b...)
}
config.Confinement.Sandbox = conf
}
seal.sys.bwrap = config.Confinement.Sandbox.Bwrap()
seal.sys.override = config.Confinement.Sandbox.Override
if seal.sys.bwrap.SetEnv == nil {
seal.sys.bwrap.SetEnv = make(map[string]string)
}
// create wayland struct and client wait channel if mediated wayland is enabled
// this field being set enables mediated wayland setup later on
if config.Confinement.Sandbox.Wayland {
seal.wl = shim.NewWayland()
} }
// open process state store // open process state store
// the simple store only starts holding an open file after first action // the simple store only starts holding an open file after first action
// store activity begins after Start is called and must end before Wait // store activity begins after Start is called and must end before Wait
seal.store = state.NewSimple(seal.RunDirPath, seal.sys.user.Uid) seal.store = state.NewSimple(seal.SystemConstants.RunDirPath, seal.sys.Uid)
// parse string UID // parse string UID
if u, err := strconv.Atoi(seal.sys.user.Uid); err != nil { if u, err := strconv.Atoi(seal.sys.Uid); err != nil {
// unreachable unless kernel bug // unreachable unless kernel bug
panic("uid parse") panic("uid parse")
} else { } else {
seal.sys.I = system.New(u) seal.sys.uid = u
} }
// pass through enablements // pass through enablements
seal.et = config.Confinement.Enablements seal.et = config.Confinement.Enablements
// this method calls all share methods in sequence // this method calls all share methods in sequence
if err := seal.shareAll([2]*dbus.Config{config.Confinement.SessionBus, config.Confinement.SystemBus}, a.os); err != nil { if err := seal.shareAll([2]*dbus.Config{config.Confinement.SessionBus, config.Confinement.SystemBus}); err != nil {
return err return err
} }
// verbose log seal information // verbose log seal information
fmsg.VPrintln("created application seal as user", verbose.Println("created application seal as user",
seal.sys.user.Username, "("+seal.sys.user.Uid+"),", seal.sys.Username, "("+seal.sys.Uid+"),",
"method:", config.Method+",", "method:", config.Method+",",
"launcher:", seal.toolPath+",", "launcher:", seal.toolPath+",",
"command:", config.Command) "command:", config.Command)

View File

@ -1,11 +1,15 @@
package app package app
import ( import (
"errors"
"fmt"
"os"
"path" "path"
"git.ophivana.moe/security/fortify/acl" "git.ophivana.moe/cat/fortify/acl"
"git.ophivana.moe/security/fortify/dbus" "git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/security/fortify/internal/system" "git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/verbose"
) )
const ( const (
@ -13,30 +17,118 @@ const (
dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS" dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS"
) )
var (
ErrDBusConfig = errors.New("dbus config not supplied")
)
type (
SealDBusError BaseError
LookupDBusError BaseError
StartDBusError BaseError
CloseDBusError BaseError
)
func (seal *appSeal) shareDBus(config [2]*dbus.Config) error { func (seal *appSeal) shareDBus(config [2]*dbus.Config) error {
if !seal.et.Has(system.EDBus) { if !seal.et.Has(state.EnableDBus) {
return nil return nil
} }
// session bus is mandatory
if config[0] == nil {
return (*SealDBusError)(wrapError(ErrDBusConfig, "attempted to seal session bus proxy with nil config"))
}
// system bus is optional
seal.sys.dbusSystem = config[1] != nil
// upstream address, downstream socket path
var sessionBus, systemBus [2]string
// downstream socket paths // downstream socket paths
sessionPath, systemPath := path.Join(seal.share, "bus"), path.Join(seal.share, "system_bus_socket") sessionBus[1] = path.Join(seal.share, "bus")
systemBus[1] = path.Join(seal.share, "system_bus_socket")
// configure dbus proxy // resolve upstream bus addresses
if err := seal.sys.ProxyDBus(config[0], config[1], sessionPath, systemPath); err != nil { sessionBus[0], systemBus[0] = dbus.Address()
return err
// create proxy instance
seal.sys.dbus = dbus.New(sessionBus, systemBus)
// seal dbus proxy
if err := seal.sys.dbus.Seal(config[0], config[1]); err != nil {
return (*SealDBusError)(wrapError(err, "cannot seal message bus proxy:", err))
} }
// store addresses for cleanup and logging
seal.sys.dbusAddr = &[2][2]string{sessionBus, systemBus}
// share proxy sockets // share proxy sockets
sessionInner := path.Join(seal.sys.runtime, "bus") seal.appendEnv(dbusSessionBusAddress, "unix:path="+sessionBus[1])
seal.sys.bwrap.SetEnv[dbusSessionBusAddress] = "unix:path=" + sessionInner seal.sys.updatePerm(sessionBus[1], acl.Read, acl.Write)
seal.sys.bwrap.Bind(sessionPath, sessionInner) if seal.sys.dbusSystem {
seal.sys.UpdatePerm(sessionPath, acl.Read, acl.Write) seal.appendEnv(dbusSystemBusAddress, "unix:path="+systemBus[1])
if config[1] != nil { seal.sys.updatePerm(systemBus[1], acl.Read, acl.Write)
systemInner := "/run/dbus/system_bus_socket"
seal.sys.bwrap.SetEnv[dbusSystemBusAddress] = "unix:path=" + systemInner
seal.sys.bwrap.Bind(systemPath, systemInner)
seal.sys.UpdatePerm(systemPath, acl.Read, acl.Write)
} }
return nil return nil
} }
func (tx *appSealTx) startDBus() error {
// ready channel passed to dbus package
ready := make(chan error, 1)
// used by waiting goroutine to notify process return
tx.dbusWait = make(chan struct{})
// background dbus proxy start
if err := tx.dbus.Start(ready, os.Stderr, true); err != nil {
return (*StartDBusError)(wrapError(err, "cannot start message bus proxy:", err))
}
verbose.Println("starting message bus proxy:", tx.dbus)
verbose.Println("message bus proxy bwrap args:", tx.dbus.Bwrap())
// background wait for proxy instance and notify completion
go func() {
if err := tx.dbus.Wait(); err != nil {
fmt.Println("fortify: warn: message bus proxy returned error:", err)
go func() { ready <- err }()
} else {
verbose.Println("message bus proxy exit")
}
// ensure socket removal so ephemeral directory is empty at revert
if err := os.Remove(tx.dbusAddr[0][1]); err != nil && !errors.Is(err, os.ErrNotExist) {
fmt.Println("fortify: cannot remove dangling session bus socket:", err)
}
if tx.dbusSystem {
if err := os.Remove(tx.dbusAddr[1][1]); err != nil && !errors.Is(err, os.ErrNotExist) {
fmt.Println("fortify: cannot remove dangling system bus socket:", err)
}
}
// notify proxy completion
tx.dbusWait <- struct{}{}
}()
// ready is not nil if the proxy process faulted
if err := <-ready; err != nil {
// note that err here is either an I/O related error or a predetermined unexpected behaviour error
return (*StartDBusError)(wrapError(err, "message bus proxy fault after start:", err))
}
verbose.Println("message bus proxy ready")
return nil
}
func (tx *appSealTx) stopDBus() error {
if err := tx.dbus.Close(); err != nil {
if errors.Is(err, os.ErrClosed) {
return (*CloseDBusError)(wrapError(err, "message bus proxy already closed"))
} else {
return (*CloseDBusError)(wrapError(err, "cannot close message bus proxy:", err))
}
}
// block until proxy wait returns
<-tx.dbusWait
return nil
}

View File

@ -2,12 +2,11 @@ package app
import ( import (
"errors" "errors"
"os"
"path" "path"
"git.ophivana.moe/security/fortify/acl" "git.ophivana.moe/cat/fortify/acl"
"git.ophivana.moe/security/fortify/internal" "git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/internal/system"
) )
const ( const (
@ -23,44 +22,36 @@ var (
ErrXDisplay = errors.New(display + " unset") ErrXDisplay = errors.New(display + " unset")
) )
func (seal *appSeal) shareDisplay(os internal.System) error { type ErrDisplayEnv BaseError
func (seal *appSeal) shareDisplay() error {
// pass $TERM to launcher // pass $TERM to launcher
if t, ok := os.LookupEnv(term); ok { if t, ok := os.LookupEnv(term); ok {
seal.sys.bwrap.SetEnv[term] = t seal.appendEnv(term, t)
} }
// set up wayland // set up wayland
if seal.et.Has(system.EWayland) { if seal.et.Has(state.EnableWayland) {
if wd, ok := os.LookupEnv(waylandDisplay); !ok { if wd, ok := os.LookupEnv(waylandDisplay); !ok {
return fmsg.WrapError(ErrWayland, return (*ErrDisplayEnv)(wrapError(ErrWayland, "WAYLAND_DISPLAY is not set"))
"WAYLAND_DISPLAY is not set") } else {
} else if seal.wl == nil { // wayland socket path
// hardlink wayland socket
wp := path.Join(seal.RuntimePath, wd) wp := path.Join(seal.RuntimePath, wd)
wpi := path.Join(seal.shareLocal, "wayland") seal.appendEnv(waylandDisplay, wp)
w := path.Join(seal.sys.runtime, "wayland-0")
seal.sys.Link(wp, wpi)
seal.sys.bwrap.SetEnv[waylandDisplay] = w
seal.sys.bwrap.Bind(wpi, w)
// ensure Wayland socket ACL (e.g. `/run/user/%d/wayland-%d`) // ensure Wayland socket ACL (e.g. `/run/user/%d/wayland-%d`)
seal.sys.UpdatePermType(system.EWayland, wp, acl.Read, acl.Write, acl.Execute) seal.sys.updatePerm(wp, acl.Read, acl.Write, acl.Execute)
} else {
// set wayland socket path for mediation (e.g. `/run/user/%d/wayland-%d`)
seal.wl.Path = path.Join(seal.RuntimePath, wd)
} }
} }
// set up X11 // set up X11
if seal.et.Has(system.EX11) { if seal.et.Has(state.EnableX) {
// discover X11 and grant user permission via the `ChangeHosts` command // discover X11 and grant user permission via the `ChangeHosts` command
if d, ok := os.LookupEnv(display); !ok { if d, ok := os.LookupEnv(display); !ok {
return fmsg.WrapError(ErrXDisplay, return (*ErrDisplayEnv)(wrapError(ErrXDisplay, "DISPLAY is not set"))
"DISPLAY is not set")
} else { } else {
seal.sys.ChangeHosts(seal.sys.user.Username) seal.sys.changeHosts(seal.sys.Username)
seal.sys.bwrap.SetEnv[display] = d seal.appendEnv(display, d)
seal.sys.bwrap.Bind("/tmp/.X11-unix", "/tmp/.X11-unix")
} }
} }

View File

@ -4,11 +4,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os"
"path" "path"
"git.ophivana.moe/security/fortify/internal" "git.ophivana.moe/cat/fortify/acl"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/security/fortify/internal/system"
) )
const ( const (
@ -25,60 +25,60 @@ var (
ErrPulseMode = errors.New("unexpected pulse socket mode") ErrPulseMode = errors.New("unexpected pulse socket mode")
) )
func (seal *appSeal) sharePulse(os internal.System) error { type (
if !seal.et.Has(system.EPulse) { PulseCookieAccessError BaseError
PulseSocketAccessError BaseError
)
func (seal *appSeal) sharePulse() error {
if !seal.et.Has(state.EnablePulse) {
return nil return nil
} }
// check PulseAudio directory presence (e.g. `/run/user/%d/pulse`) // ensure PulseAudio directory ACL (e.g. `/run/user/%d/pulse`)
pd := path.Join(seal.RuntimePath, "pulse") pd := path.Join(seal.RuntimePath, "pulse")
ps := path.Join(pd, "native") ps := path.Join(pd, "native")
if _, err := os.Stat(pd); err != nil { if _, err := os.Stat(pd); err != nil {
if !errors.Is(err, fs.ErrNotExist) { if !errors.Is(err, fs.ErrNotExist) {
return fmsg.WrapErrorSuffix(err, return (*PulseSocketAccessError)(wrapError(err,
fmt.Sprintf("cannot access PulseAudio directory %q:", pd)) fmt.Sprintf("cannot access PulseAudio directory '%s':", pd), err))
} }
return fmsg.WrapError(ErrPulseSocket, return (*PulseSocketAccessError)(wrapError(ErrPulseSocket,
fmt.Sprintf("PulseAudio directory %q not found", pd)) fmt.Sprintf("PulseAudio directory '%s' not found", pd)))
} }
// check PulseAudio socket permission (e.g. `/run/user/%d/pulse/native`) seal.appendEnv(pulseServer, "unix:"+ps)
seal.sys.updatePerm(pd, acl.Execute)
// ensure PulseAudio socket permission (e.g. `/run/user/%d/pulse/native`)
if s, err := os.Stat(ps); err != nil { if s, err := os.Stat(ps); err != nil {
if !errors.Is(err, fs.ErrNotExist) { if !errors.Is(err, fs.ErrNotExist) {
return fmsg.WrapErrorSuffix(err, return (*PulseSocketAccessError)(wrapError(err,
fmt.Sprintf("cannot access PulseAudio socket %q:", ps)) fmt.Sprintf("cannot access PulseAudio socket '%s':", ps), err))
} }
return fmsg.WrapError(ErrPulseSocket, return (*PulseSocketAccessError)(wrapError(ErrPulseSocket,
fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pd)) fmt.Sprintf("PulseAudio directory '%s' found but socket does not exist", pd)))
} else { } else {
if m := s.Mode(); m&0o006 != 0o006 { if m := s.Mode(); m&0o006 != 0o006 {
return fmsg.WrapError(ErrPulseMode, return (*PulseSocketAccessError)(wrapError(ErrPulseMode,
fmt.Sprintf("unexpected permissions on %q:", ps), m) fmt.Sprintf("unexpected permissions on '%s':", ps), m))
} }
} }
// hard link pulse socket into target-executable share
psi := path.Join(seal.shareLocal, "pulse")
p := path.Join(seal.sys.runtime, "pulse", "native")
seal.sys.Link(ps, psi)
seal.sys.bwrap.Bind(psi, p)
seal.sys.bwrap.SetEnv[pulseServer] = "unix:" + p
// publish current user's pulse cookie for target user // publish current user's pulse cookie for target user
if src, err := discoverPulseCookie(os); err != nil { if src, err := discoverPulseCookie(); err != nil {
return err return err
} else { } else {
dst := path.Join(seal.share, "pulse-cookie") dst := path.Join(seal.share, "pulse-cookie")
seal.sys.bwrap.SetEnv[pulseCookie] = dst seal.appendEnv(pulseCookie, dst)
seal.sys.CopyFile(dst, src) seal.sys.copyFile(dst, src)
seal.sys.bwrap.Bind(dst, dst)
} }
return nil return nil
} }
// discoverPulseCookie attempts various standard methods to discover the current user's PulseAudio authentication cookie // discoverPulseCookie attempts various standard methods to discover the current user's PulseAudio authentication cookie
func discoverPulseCookie(os internal.System) (string, error) { func discoverPulseCookie() (string, error) {
if p, ok := os.LookupEnv(pulseCookie); ok { if p, ok := os.LookupEnv(pulseCookie); ok {
return p, nil return p, nil
} }
@ -88,8 +88,8 @@ func discoverPulseCookie(os internal.System) (string, error) {
p = path.Join(p, ".pulse-cookie") p = path.Join(p, ".pulse-cookie")
if s, err := os.Stat(p); err != nil { if s, err := os.Stat(p); err != nil {
if !errors.Is(err, fs.ErrNotExist) { if !errors.Is(err, fs.ErrNotExist) {
return p, fmsg.WrapErrorSuffix(err, return p, (*PulseCookieAccessError)(wrapError(err,
fmt.Sprintf("cannot access PulseAudio cookie %q:", p)) fmt.Sprintf("cannot access PulseAudio cookie '%s':", p), err))
} }
// not found, try next method // not found, try next method
} else if !s.IsDir() { } else if !s.IsDir() {
@ -102,8 +102,7 @@ func discoverPulseCookie(os internal.System) (string, error) {
p = path.Join(p, "pulse", "cookie") p = path.Join(p, "pulse", "cookie")
if s, err := os.Stat(p); err != nil { if s, err := os.Stat(p); err != nil {
if !errors.Is(err, fs.ErrNotExist) { if !errors.Is(err, fs.ErrNotExist) {
return p, fmsg.WrapErrorSuffix(err, return p, (*PulseCookieAccessError)(wrapError(err, "cannot access PulseAudio cookie", p+":", err))
fmt.Sprintf("cannot access PulseAudio cookie %q:", p))
} }
// not found, try next method // not found, try next method
} else if !s.IsDir() { } else if !s.IsDir() {
@ -111,7 +110,7 @@ func discoverPulseCookie(os internal.System) (string, error) {
} }
} }
return "", fmsg.WrapError(ErrPulseCookie, return "", (*PulseCookieAccessError)(wrapError(ErrPulseCookie,
fmt.Sprintf("cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)", fmt.Sprintf("cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)",
pulseCookie, xdgConfigHome, home)) pulseCookie, xdgConfigHome, home)))
} }

View File

@ -3,8 +3,7 @@ package app
import ( import (
"path" "path"
"git.ophivana.moe/security/fortify/acl" "git.ophivana.moe/cat/fortify/acl"
"git.ophivana.moe/security/fortify/internal/system"
) )
const ( const (
@ -15,25 +14,37 @@ const (
// shareRuntime queues actions for sharing/ensuring the runtime and share directories // shareRuntime queues actions for sharing/ensuring the runtime and share directories
func (seal *appSeal) shareRuntime() { func (seal *appSeal) shareRuntime() {
// mount tmpfs on inner runtime (e.g. `/run/user/%d`)
seal.sys.bwrap.Tmpfs("/run/user", 1*1024*1024)
seal.sys.bwrap.Tmpfs(seal.sys.runtime, 8*1024*1024)
// point to inner runtime path `/run/user/%d`
seal.sys.bwrap.SetEnv[xdgRuntimeDir] = seal.sys.runtime
seal.sys.bwrap.SetEnv[xdgSessionClass] = "user"
seal.sys.bwrap.SetEnv[xdgSessionType] = "tty"
// ensure RunDir (e.g. `/run/user/%d/fortify`) // ensure RunDir (e.g. `/run/user/%d/fortify`)
seal.sys.Ensure(seal.RunDirPath, 0700) seal.sys.ensure(seal.RunDirPath, 0700)
seal.sys.UpdatePermType(system.User, seal.RunDirPath, acl.Execute)
// ensure runtime directory ACL (e.g. `/run/user/%d`) // ensure runtime directory ACL (e.g. `/run/user/%d`)
seal.sys.Ensure(seal.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset seal.sys.updatePerm(seal.RuntimePath, acl.Execute)
seal.sys.UpdatePermType(system.User, seal.RuntimePath, acl.Execute)
// ensure process-specific share local to XDG_RUNTIME_DIR (e.g. `/run/user/%d/fortify/%s`) // ensure Share (e.g. `/tmp/fortify.%d`)
seal.shareLocal = path.Join(seal.RunDirPath, seal.id) // acl is unnecessary as this directory is world executable
seal.sys.Ephemeral(system.Process, seal.shareLocal, 0700) seal.sys.ensure(seal.SharePath, 0701)
seal.sys.UpdatePerm(seal.shareLocal, acl.Execute)
// ensure process-specific share (e.g. `/tmp/fortify.%d/%s`)
// acl is unnecessary as this directory is world executable
seal.share = path.Join(seal.SharePath, seal.id.String())
seal.sys.ensureEphemeral(seal.share, 0701)
}
func (seal *appSeal) shareRuntimeChild() string {
// ensure child runtime parent directory (e.g. `/tmp/fortify.%d/runtime`)
targetRuntimeParent := path.Join(seal.SharePath, "runtime")
seal.sys.ensure(targetRuntimeParent, 0700)
seal.sys.updatePerm(targetRuntimeParent, acl.Execute)
// ensure child runtime directory (e.g. `/tmp/fortify.%d/runtime/%d`)
targetRuntime := path.Join(targetRuntimeParent, seal.sys.Uid)
seal.sys.ensure(targetRuntime, 0700)
seal.sys.updatePerm(targetRuntime, acl.Read, acl.Write, acl.Execute)
// point to ensured runtime path
seal.appendEnv(xdgRuntimeDir, targetRuntime)
seal.appendEnv(xdgSessionClass, "user")
seal.appendEnv(xdgSessionType, "tty")
return targetRuntime
} }

View File

@ -1,71 +0,0 @@
package app
import (
"path"
"git.ophivana.moe/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal"
"git.ophivana.moe/security/fortify/internal/system"
)
const (
shell = "SHELL"
)
// shareSystem queues various system-related actions
func (seal *appSeal) shareSystem() {
// ensure Share (e.g. `/tmp/fortify.%d`)
// acl is unnecessary as this directory is world executable
seal.sys.Ensure(seal.SharePath, 0701)
// ensure process-specific share (e.g. `/tmp/fortify.%d/%s`)
// acl is unnecessary as this directory is world executable
seal.share = path.Join(seal.SharePath, seal.id)
seal.sys.Ephemeral(system.Process, seal.share, 0701)
// ensure child tmpdir parent directory (e.g. `/tmp/fortify.%d/tmpdir`)
targetTmpdirParent := path.Join(seal.SharePath, "tmpdir")
seal.sys.Ensure(targetTmpdirParent, 0700)
seal.sys.UpdatePermType(system.User, targetTmpdirParent, acl.Execute)
// ensure child tmpdir (e.g. `/tmp/fortify.%d/tmpdir/%d`)
targetTmpdir := path.Join(targetTmpdirParent, seal.sys.user.Uid)
seal.sys.Ensure(targetTmpdir, 01700)
seal.sys.UpdatePermType(system.User, targetTmpdir, acl.Read, acl.Write, acl.Execute)
seal.sys.bwrap.Bind(targetTmpdir, "/tmp", false, true)
// mount tmpfs on inner shared directory (e.g. `/tmp/fortify.%d`)
seal.sys.bwrap.Tmpfs(seal.SharePath, 1*1024*1024)
}
func (seal *appSeal) sharePasswd(os internal.System) {
// look up shell
sh := "/bin/sh"
if s, ok := os.LookupEnv(shell); ok {
seal.sys.bwrap.SetEnv[shell] = s
sh = s
}
// generate /etc/passwd
passwdPath := path.Join(seal.share, "passwd")
username := "chronos"
if seal.sys.user.Username != "" {
username = seal.sys.user.Username
seal.sys.bwrap.SetEnv["USER"] = seal.sys.user.Username
}
homeDir := "/var/empty"
if seal.sys.user.HomeDir != "" {
homeDir = seal.sys.user.HomeDir
seal.sys.bwrap.SetEnv["HOME"] = seal.sys.user.HomeDir
}
passwd := username + ":x:65534:65534:Fortify:" + homeDir + ":" + sh + "\n"
seal.sys.Write(passwdPath, passwd)
// write /etc/group
groupPath := path.Join(seal.share, "group")
seal.sys.Write(groupPath, "fortify:x:65534:\n")
// bind /etc/passwd and /etc/group
seal.sys.bwrap.Bind(passwdPath, "/etc/passwd")
seal.sys.bwrap.Bind(groupPath, "/etc/group")
}

83
internal/app/shim.go Normal file
View File

@ -0,0 +1,83 @@
package app
import (
"bytes"
"encoding/base64"
"encoding/gob"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
)
const shimPayload = "FORTIFY_SHIM_PAYLOAD"
func (a *app) shimPayloadEnv() string {
r := &bytes.Buffer{}
enc := base64.NewEncoder(base64.StdEncoding, r)
if err := gob.NewEncoder(enc).Encode(a.seal.command); err != nil {
// should be unreachable
panic(err)
}
_ = enc.Close()
return shimPayload + "=" + r.String()
}
// TryShim attempts the early hidden launcher shim path
func TryShim() {
// environment variable contains encoded argv
if r, ok := os.LookupEnv(shimPayload); ok {
// everything beyond this point runs as target user
// proceed with caution!
// parse base64 revealing underlying gob stream
dec := base64.NewDecoder(base64.StdEncoding, strings.NewReader(r))
// decode argv gob stream
var argv []string
if err := gob.NewDecoder(dec).Decode(&argv); err != nil {
fmt.Println("fortify-shim: cannot decode shim payload:", err)
os.Exit(1)
}
// remove payload variable since the child does not need to see it
if err := os.Unsetenv(shimPayload); err != nil {
fmt.Println("fortify-shim: cannot unset shim payload:", err)
// not fatal, do not fail
}
// look up argv0
var argv0 string
if len(argv) > 0 {
// look up program from $PATH
if p, err := exec.LookPath(argv[0]); err != nil {
fmt.Printf("%s not found: %s\n", argv[0], err)
os.Exit(1)
} else {
argv0 = p
}
} else {
// no argv, look up shell instead
if argv0, ok = os.LookupEnv("SHELL"); !ok {
fmt.Println("fortify-shim: no command was specified and $SHELL was unset")
os.Exit(1)
}
argv = []string{argv0}
}
// exec target process
if err := syscall.Exec(argv0, argv, os.Environ()); err != nil {
fmt.Println("fortify-shim: cannot execute shim payload:", err)
os.Exit(1)
}
// unreachable
os.Exit(1)
return
}
}

View File

@ -2,97 +2,73 @@ package app
import ( import (
"errors" "errors"
"fmt" "os"
"os/exec" "os/exec"
"path" "strconv"
"path/filepath" "time"
"strings"
"git.ophivana.moe/security/fortify/helper" "git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.ophivana.moe/cat/fortify/internal/verbose"
"git.ophivana.moe/security/fortify/internal/shim"
"git.ophivana.moe/security/fortify/internal/state"
"git.ophivana.moe/security/fortify/internal/system"
) )
// Start selects a user switcher and starts shim. type (
// Note that Wait must be called regardless of error returned by Start. // ProcessError encapsulates errors returned by starting *exec.Cmd
ProcessError BaseError
)
// Start starts the fortified child
func (a *app) Start() error { func (a *app) Start() error {
a.lock.Lock() a.lock.Lock()
defer a.lock.Unlock() defer a.lock.Unlock()
// resolve exec paths if err := a.seal.sys.commit(); err != nil {
shimExec := [3]string{a.seal.sys.executable, helper.BubblewrapName} return err
if len(a.seal.command) > 0 {
shimExec[2] = a.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))
}
}
} }
// select command builder // select command builder
var commandBuilder shim.CommandBuilder var commandBuilder func() (args []string)
switch a.seal.launchOption { switch a.seal.launchOption {
case LaunchMethodSudo: case LaunchMethodSudo:
commandBuilder = a.commandBuilderSudo commandBuilder = a.commandBuilderSudo
case LaunchMethodBwrap:
commandBuilder = a.commandBuilderBwrap
case LaunchMethodMachineCtl: case LaunchMethodMachineCtl:
commandBuilder = a.commandBuilderMachineCtl commandBuilder = a.commandBuilderMachineCtl
default: default:
panic("unreachable") panic("unreachable")
} }
// construct shim manager // configure child process
a.shim = shim.New(a.seal.toolPath, uint32(a.seal.sys.UID()), path.Join(a.seal.share, "shim"), a.seal.wl, a.cmd = exec.Command(a.seal.toolPath, commandBuilder()...)
&shim.Payload{ a.cmd.Env = []string{}
Argv: a.seal.command, a.cmd.Stdin = os.Stdin
Exec: shimExec, a.cmd.Stdout = os.Stdout
Bwrap: a.seal.sys.bwrap, a.cmd.Stderr = os.Stderr
WL: a.seal.wl != nil, a.cmd.Dir = a.seal.RunDirPath
Verbose: fmsg.Verbose(), // start child process
}, verbose.Println("starting main process:", a.cmd)
// checkPid is impossible at the moment since there is no reliable way to obtain shim's pid if err := a.cmd.Start(); err != nil {
// this feature is disabled here until sudo is replaced by fortify suid wrapper return (*ProcessError)(wrapError(err, "cannot start process:", err))
false,
)
// startup will go ahead, commit system setup
if err := a.seal.sys.Commit(); err != nil {
return err
} }
a.seal.sys.needRevert = true startTime := time.Now().UTC()
if startTime, err := a.shim.Start(commandBuilder); err != nil { // create process state
return err
} else {
// shim start and setup success, create process state
sd := state.State{ sd := state.State{
PID: a.shim.Unwrap().Process.Pid, PID: a.cmd.Process.Pid,
Command: a.seal.command, Command: a.seal.command,
Capability: a.seal.et, Capability: a.seal.et,
Method: method[a.seal.launchOption], Launcher: a.seal.toolPath,
Argv: a.shim.Unwrap().Args, Argv: a.cmd.Args,
Time: *startTime, Time: startTime,
} }
// register process state // register process state
var err0 = new(StateStoreError) var e = new(StateStoreError)
err0.Inner, err0.DoErr = a.seal.store.Do(func(b state.Backend) { e.Inner, e.DoErr = a.seal.store.Do(func(b state.Backend) {
err0.InnerErr = b.Save(&sd) e.InnerErr = b.Save(&sd)
}) })
a.seal.sys.saveState = true return e.equiv("cannot save process state:", e)
return err0.equiv("cannot save process state:")
}
} }
// StateStoreError is returned for a failed state save // StateStoreError is returned for a failed state save
@ -108,10 +84,10 @@ type StateStoreError struct {
} }
func (e *StateStoreError) equiv(a ...any) error { func (e *StateStoreError) equiv(a ...any) error {
if e.Inner && e.DoErr == nil && e.InnerErr == nil && e.Err == nil { if e.Inner == true && e.DoErr == nil && e.InnerErr == nil && e.Err == nil {
return nil return nil
} else { } else {
return fmsg.WrapErrorSuffix(e, a...) return wrapError(e, a...)
} }
} }
@ -154,94 +130,51 @@ func (a *app) Wait() (int, error) {
a.lock.Lock() a.lock.Lock()
defer a.lock.Unlock() defer a.lock.Unlock()
if a.shim == nil {
fmsg.VPrintln("shim not initialised, skipping cleanup")
return 1, nil
}
var r int var r int
if cmd := a.shim.Unwrap(); cmd == nil {
// failure prior to process start
r = 255
} else {
// wait for process and resolve exit code // wait for process and resolve exit code
if err := cmd.Wait(); err != nil { if err := a.cmd.Wait(); err != nil {
var exitError *exec.ExitError var exitError *exec.ExitError
if !errors.As(err, &exitError) { if !errors.As(err, &exitError) {
// should be unreachable // should be unreachable
a.waitErr = err a.wait = err
} }
// store non-zero return code // store non-zero return code
r = exitError.ExitCode() r = exitError.ExitCode()
} else { } else {
r = cmd.ProcessState.ExitCode() r = a.cmd.ProcessState.ExitCode()
}
fmsg.VPrintf("process %d exited with exit code %d", cmd.Process.Pid, r)
} }
// child process exited, resume output verbose.Println("process", strconv.Itoa(a.cmd.Process.Pid), "exited with exit code", r)
fmsg.Resume()
// close wayland connection
if a.seal.wl != nil {
if err := a.seal.wl.Close(); err != nil {
fmsg.Println("cannot close wayland connection:", err)
}
}
// update store and revert app setup transaction // update store and revert app setup transaction
e := new(StateStoreError) e := new(StateStoreError)
e.Inner, e.DoErr = a.seal.store.Do(func(b state.Backend) { e.Inner, e.DoErr = a.seal.store.Do(func(b state.Backend) {
e.InnerErr = func() error { e.InnerErr = func() error {
// destroy defunct state entry // destroy defunct state entry
if cmd := a.shim.Unwrap(); cmd != nil && a.seal.sys.saveState { if err := b.Destroy(a.cmd.Process.Pid); err != nil {
if err := b.Destroy(cmd.Process.Pid); err != nil {
return err return err
} }
}
// enablements of remaining launchers var global bool
rt, ec := new(system.Enablements), new(system.Criteria)
ec.Enablements = new(system.Enablements) // measure remaining state entries
ec.Set(system.Process) if l, err := b.Len(); err != nil {
if states, err := b.Load(); err != nil {
return err return err
} else { } else {
if l := len(states); l == 0 { // clean up global modifications if we're the last launcher alive
// cleanup globals as the final launcher global = l == 0
fmsg.VPrintln("no other launchers active, will clean up globals")
ec.Set(system.User) if !global {
verbose.Printf("found %d active launchers, cleaning up without globals\n", l)
} else { } else {
fmsg.VPrintf("found %d active launchers, cleaning up without globals", l) verbose.Println("no other launchers active, will clean up globals")
}
// accumulate capabilities of other launchers
for _, s := range states {
*rt |= s.Capability
}
}
// 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.Verbose() {
labels := make([]string, 0, system.ELen+1)
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.VPrintln("reverting operations labelled", strings.Join(labels, ", "))
} }
} }
a.shim.AbortWait(errors.New("shim exited")) // FIXME: depending on exit sequence, some parts of the transaction never gets reverted
if err := a.seal.sys.Revert(ec); err != nil { if err := a.seal.sys.revert(global); err != nil {
return err.(RevertCompoundError) return err.(RevertCompoundError)
} }

View File

@ -1,48 +1,327 @@
package app package app
import ( import (
"errors"
"fmt"
"io/fs"
"os"
"os/user" "os/user"
"git.ophivana.moe/security/fortify/dbus" "git.ophivana.moe/cat/fortify/acl"
"git.ophivana.moe/security/fortify/helper/bwrap" "git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/security/fortify/internal" "git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/security/fortify/internal/system" "git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/verbose"
"git.ophivana.moe/cat/fortify/xcb"
) )
// appSealSys encapsulates app seal behaviour with OS interactions // appSeal seals the application with child-related information
type appSealSys struct { type appSeal struct {
bwrap *bwrap.Config // application unique identifier
// paths to override by mounting tmpfs over them id *appID
override []string
// default formatted XDG_RUNTIME_DIR of User // freedesktop application ID
runtime string fid string
// sealed path to fortify executable, used by shim // argv to start process with in the final confined environment
executable string command []string
// target user sealed from config // environment variables of fortified process
user *user.User env []string
// persistent process state store
store state.Store
needRevert bool // uint8 representation of launch method sealed from config
saveState bool launchOption uint8
*system.I // process-specific share directory path
share string
// path to launcher program
toolPath string
// pass-through enablement tracking from config
et state.Enablements
// prevents sharing from happening twice
shared bool
// seal system-level component
sys *appSealTx
// used in various sealing operations
internal.SystemConstants
// protected by upstream mutex // protected by upstream mutex
} }
// appendEnv appends an environment variable for the child process
func (seal *appSeal) appendEnv(k, v string) {
seal.env = append(seal.env, k+"="+v)
}
// appSealTx contains the system-level component of the app seal
type appSealTx struct {
// reference to D-Bus proxy instance, nil if disabled
dbus *dbus.Proxy
// notification from goroutine waiting for dbus.Proxy
dbusWait chan struct{}
// upstream address/downstream path used to initialise dbus.Proxy
dbusAddr *[2][2]string
// whether system bus proxy is enabled
dbusSystem bool
// paths to append/strip ACLs (of target user) from
acl []*appACLEntry
// X11 ChangeHosts commands to perform
xhost []string
// paths of directories to ensure
mkdir []appEnsureEntry
// dst, src pairs of temporarily shared files
tmpfiles [][2]string
// sealed path to fortify executable, used by shim
executable string
// target user UID as an integer
uid int
// target user sealed from config
*user.User
// prevents commit from happening twice
complete bool
// prevents cleanup from happening twice
closed bool
// protected by upstream mutex
}
type appEnsureEntry struct {
path string
perm os.FileMode
remove bool
}
// ensure appends a directory ensure action
func (tx *appSealTx) ensure(path string, perm os.FileMode) {
tx.mkdir = append(tx.mkdir, appEnsureEntry{path, perm, false})
}
// ensureEphemeral appends a directory ensure action with removal in rollback
func (tx *appSealTx) ensureEphemeral(path string, perm os.FileMode) {
tx.mkdir = append(tx.mkdir, appEnsureEntry{path, perm, true})
}
// appACLEntry contains information for applying/reverting an ACL entry
type appACLEntry struct {
path string
perms []acl.Perm
}
func (e *appACLEntry) String() string {
var s = []byte("---")
for _, p := range e.perms {
switch p {
case acl.Read:
s[0] = 'r'
case acl.Write:
s[1] = 'w'
case acl.Execute:
s[2] = 'x'
}
}
return string(s)
}
// updatePerm appends an acl update action
func (tx *appSealTx) updatePerm(path string, perms ...acl.Perm) {
tx.acl = append(tx.acl, &appACLEntry{path, perms})
}
// changeHosts appends target username of an X11 ChangeHosts action
func (tx *appSealTx) changeHosts(username string) {
tx.xhost = append(tx.xhost, username)
}
// copyFile appends a tmpfiles action
func (tx *appSealTx) copyFile(dst, src string) {
tx.tmpfiles = append(tx.tmpfiles, [2]string{dst, src})
tx.updatePerm(dst, acl.Read)
}
type (
ChangeHostsError BaseError
EnsureDirError BaseError
TmpfileError BaseError
DBusStartError BaseError
ACLUpdateError BaseError
)
// commit applies recorded actions
// order: xhost, mkdir, tmpfiles, dbus, acl
func (tx *appSealTx) commit() error {
if tx.complete {
panic("seal transaction committed twice")
}
tx.complete = true
txp := &appSealTx{}
defer func() {
// rollback partial commit
if txp != nil {
// global changes (x11, ACLs) are always repeated and check for other launchers cannot happen here
// attempting cleanup here will cause other fortified processes to lose access to them
// a better (and more secure) fix is to proxy access to these resources and eliminate the ACLs altogether
if err := txp.revert(false); err != nil {
fmt.Println("fortify: errors returned reverting partial commit:", err)
}
}
}()
// insert xhost entries
for _, username := range tx.xhost {
verbose.Printf("inserting XHost entry SI:localuser:%s\n", username)
if err := xcb.ChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+username); err != nil {
return (*ChangeHostsError)(wrapError(err,
fmt.Sprintf("cannot insert XHost entry SI:localuser:%s, %s", username, err)))
} else {
// register partial commit
txp.changeHosts(username)
}
}
// ensure directories
for _, dir := range tx.mkdir {
verbose.Println("ensuring directory mode:", dir.perm.String(), "path:", dir.path)
if err := os.Mkdir(dir.path, dir.perm); err != nil && !errors.Is(err, fs.ErrExist) {
return (*EnsureDirError)(wrapError(err,
fmt.Sprintf("cannot create directory '%s': %s", dir.path, err)))
} else {
// only ephemeral dirs require rollback
if dir.remove {
// register partial commit
txp.ensureEphemeral(dir.path, dir.perm)
}
}
}
// publish tmpfiles
for _, tmpfile := range tx.tmpfiles {
verbose.Println("publishing tmpfile", tmpfile[0], "from", tmpfile[1])
if err := copyFile(tmpfile[0], tmpfile[1]); err != nil {
return (*TmpfileError)(wrapError(err,
fmt.Sprintf("cannot publish tmpfile '%s' from '%s': %s", tmpfile[0], tmpfile[1], err)))
} else {
// register partial commit
txp.copyFile(tmpfile[0], tmpfile[1])
}
}
if tx.dbus != nil {
// start dbus proxy
verbose.Printf("session bus proxy on '%s' for upstream '%s'\n", tx.dbusAddr[0][1], tx.dbusAddr[0][0])
if tx.dbusSystem {
verbose.Printf("system bus proxy on '%s' for upstream '%s'\n", tx.dbusAddr[1][1], tx.dbusAddr[1][0])
}
if err := tx.startDBus(); err != nil {
return (*DBusStartError)(wrapError(err, "cannot start message bus proxy:", err))
} else {
txp.dbus = tx.dbus
txp.dbusAddr = tx.dbusAddr
txp.dbusSystem = tx.dbusSystem
txp.dbusWait = tx.dbusWait
}
}
// apply ACLs
for _, e := range tx.acl {
verbose.Println("applying ACL", e, "uid:", tx.Uid, "path:", e.path)
if err := acl.UpdatePerm(e.path, tx.uid, e.perms...); err != nil {
return (*ACLUpdateError)(wrapError(err,
fmt.Sprintf("cannot apply ACL to '%s': %s", e.path, err)))
} else {
// register partial commit
txp.updatePerm(e.path, e.perms...)
}
}
// disarm partial commit rollback
txp = nil
return nil
}
// revert rolls back recorded actions
// order: acl, dbus, tmpfiles, mkdir, xhost
// errors are printed but not treated as fatal
func (tx *appSealTx) revert(global bool) error {
if tx.closed {
panic("seal transaction reverted twice")
}
tx.closed = true
// will be slightly over-sized with ephemeral dirs
errs := make([]error, 0, len(tx.acl)+1+len(tx.tmpfiles)+len(tx.mkdir)+len(tx.xhost))
joinError := func(err error, a ...any) {
var e error
if err != nil {
e = wrapError(err, a...)
}
errs = append(errs, e)
}
if global {
// revert ACLs
for _, e := range tx.acl {
verbose.Println("stripping ACL", e, "uid:", tx.Uid, "path:", e.path)
err := acl.UpdatePerm(e.path, tx.uid)
joinError(err, fmt.Sprintf("cannot strip ACL entry from '%s': %s", e.path, err))
}
}
if tx.dbus != nil {
// stop dbus proxy
verbose.Println("terminating message bus proxy")
err := tx.stopDBus()
joinError(err, "cannot stop message bus proxy:", err)
}
// remove tmpfiles
for _, tmpfile := range tx.tmpfiles {
verbose.Println("removing tmpfile", tmpfile[0])
err := os.Remove(tmpfile[0])
joinError(err, fmt.Sprintf("cannot remove tmpfile '%s': %s", tmpfile[0], err))
}
// remove (empty) ephemeral directories
for i := len(tx.mkdir); i > 0; i-- {
dir := tx.mkdir[i-1]
if !dir.remove {
continue
}
verbose.Println("destroying ephemeral directory mode:", dir.perm.String(), "path:", dir.path)
err := os.Remove(dir.path)
joinError(err, fmt.Sprintf("cannot remove ephemeral directory '%s': %s", dir.path, err))
}
if global {
// rollback xhost insertions
for _, username := range tx.xhost {
verbose.Printf("deleting XHost entry SI:localuser:%s\n", username)
err := xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+username)
joinError(err, "cannot remove XHost entry:", err)
}
}
return errors.Join(errs...)
}
// shareAll calls all share methods in sequence // shareAll calls all share methods in sequence
func (seal *appSeal) shareAll(bus [2]*dbus.Config, os internal.System) error { func (seal *appSeal) shareAll(bus [2]*dbus.Config) error {
if seal.shared { if seal.shared {
panic("seal shared twice") panic("seal shared twice")
} }
seal.shared = true seal.shared = true
seal.shareSystem()
seal.shareRuntime() seal.shareRuntime()
seal.sharePasswd(os) if err := seal.shareDisplay(); err != nil {
if err := seal.shareDisplay(os); err != nil {
return err return err
} }
if err := seal.sharePulse(os); err != nil { if err := seal.sharePulse(); err != nil {
return err return err
} }
@ -53,11 +332,18 @@ func (seal *appSeal) shareAll(bus [2]*dbus.Config, os internal.System) error {
if err := seal.shareDBus(bus); err != nil { if err := seal.shareDBus(bus); err != nil {
return err return err
} else if seal.sys.dbusAddr != nil { // set if D-Bus enabled and share successful
verbose.Println("sealed session proxy", bus[0].Args(seal.sys.dbusAddr[0]))
if bus[1] != nil {
verbose.Println("sealed system proxy", bus[1].Args(seal.sys.dbusAddr[1]))
}
verbose.Println("message bus proxy final args:", seal.sys.dbus)
} }
// queue overriding tmpfs at the end of seal.sys.bwrap.Filesystem // workaround for launch method sudo
for _, dest := range seal.sys.override { if seal.launchOption == LaunchMethodSudo {
seal.sys.bwrap.Tmpfs(dest, 8*1024) targetRuntime := seal.shareRuntimeChild()
verbose.Printf("child runtime data dir '%s' configured\n", targetRuntime)
} }
return nil return nil

34
internal/early.go Normal file
View File

@ -0,0 +1,34 @@
package internal
import (
"errors"
"fmt"
"io/fs"
"os"
)
const (
systemdCheckPath = "/run/systemd/system"
)
var SdBootedV = func() bool {
if v, err := SdBooted(); err != nil {
fmt.Println("warn: read systemd marker:", err)
return false
} else {
return v
}
}()
// SdBooted implements https://www.freedesktop.org/software/systemd/man/sd_booted.html
func SdBooted() (bool, error) {
_, err := os.Stat(systemdCheckPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
err = nil
}
return false, err
}
return true, nil
}

59
internal/environ.go Normal file
View File

@ -0,0 +1,59 @@
package internal
import (
"fmt"
"os"
"path"
"strconv"
"sync"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
// state that remain constant for the lifetime of the process
// fetched and cached here
const (
xdgRuntimeDir = "XDG_RUNTIME_DIR"
)
// SystemConstants contains state from the operating system
type SystemConstants struct {
// path to shared directory e.g. /tmp/fortify.%d
SharePath string `json:"share_path"`
// XDG_RUNTIME_DIR value e.g. /run/user/%d
RuntimePath string `json:"runtime_path"`
// application runtime directory e.g. /run/user/%d/fortify
RunDirPath string `json:"run_dir_path"`
}
var (
scVal SystemConstants
scOnce sync.Once
)
func copySC() {
sc := SystemConstants{
SharePath: path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Geteuid())),
}
verbose.Println("process share directory at", sc.SharePath)
// runtimePath, runDirPath
if r, ok := os.LookupEnv(xdgRuntimeDir); !ok {
fmt.Println("Env variable", xdgRuntimeDir, "unset")
os.Exit(1)
} else {
sc.RuntimePath = r
sc.RunDirPath = path.Join(sc.RuntimePath, "fortify")
verbose.Println("XDG runtime directory at", sc.RunDirPath)
}
scVal = sc
}
// GetSC returns a populated SystemConstants value
func GetSC() SystemConstants {
scOnce.Do(copySC)
return scVal
}

View File

@ -1,72 +0,0 @@
package fmsg
import (
"os"
"sync"
"sync/atomic"
)
var (
wstate atomic.Bool
withhold = make(chan struct{}, 1)
msgbuf = make(chan dOp, 64) // these ops are tiny so a large buffer is allocated for withholding output
dequeueOnce sync.Once
queueSync sync.WaitGroup
)
func dequeue() {
go func() {
for {
select {
case op := <-msgbuf:
op.Do()
queueSync.Done()
case <-withhold:
<-withhold
}
}
}()
}
type dOp interface{ Do() }
func Exit(code int) {
queueSync.Wait()
os.Exit(code)
}
func Withhold() {
dequeueOnce.Do(dequeue)
if wstate.CompareAndSwap(false, true) {
withhold <- struct{}{}
}
}
func Resume() {
dequeueOnce.Do(dequeue)
if wstate.CompareAndSwap(true, false) {
withhold <- struct{}{}
}
}
type dPrint []any
func (v dPrint) Do() {
std.Print(v...)
}
type dPrintf struct {
format string
v []any
}
func (d *dPrintf) Do() {
std.Printf(d.format, d.v...)
}
type dPrintln []any
func (v dPrintln) Do() {
std.Println(v...)
}

View File

@ -1,43 +0,0 @@
// Package fmsg provides various functions for output messages.
package fmsg
import (
"log"
"os"
)
var std = log.New(os.Stderr, "fortify: ", 0)
func SetPrefix(prefix string) {
prefix += ": "
std.SetPrefix(prefix)
std.SetPrefix(prefix)
}
func Print(v ...any) {
dequeueOnce.Do(dequeue)
queueSync.Add(1)
msgbuf <- dPrint(v)
}
func Printf(format string, v ...any) {
dequeueOnce.Do(dequeue)
queueSync.Add(1)
msgbuf <- &dPrintf{format, v}
}
func Println(v ...any) {
dequeueOnce.Do(dequeue)
queueSync.Add(1)
msgbuf <- dPrintln(v)
}
func Fatal(v ...any) {
Print(v...)
Exit(1)
}
func Fatalf(format string, v ...any) {
Printf(format, v...)
Exit(1)
}

View File

@ -1,25 +0,0 @@
package fmsg
import "sync/atomic"
var verbose = new(atomic.Bool)
func Verbose() bool {
return verbose.Load()
}
func SetVerbose(v bool) {
verbose.Store(v)
}
func VPrintf(format string, v ...any) {
if verbose.Load() {
Printf(format, v...)
}
}
func VPrintln(v ...any) {
if verbose.Load() {
Println(v...)
}
}

View File

@ -1,174 +0,0 @@
package init0
import (
"encoding/gob"
"errors"
"flag"
"os"
"os/exec"
"os/signal"
"path"
"strconv"
"syscall"
"time"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
const (
// time to wait for linger processes after death initial process
residualProcessTimeout = 5 * time.Second
)
// everything beyond this point runs within pid namespace
// proceed with caution!
func doInit(fd uintptr) {
fmsg.SetPrefix("init")
// re-exec
if len(os.Args) > 0 && os.Args[0] != "fortify" && path.IsAbs(os.Args[0]) {
if err := syscall.Exec(os.Args[0], []string{"fortify", "init"}, os.Environ()); err != nil {
fmsg.Println("cannot re-exec self:", err)
// continue anyway
}
}
var payload Payload
p := os.NewFile(fd, "config-stream")
if p == nil {
fmsg.Fatal("invalid config descriptor")
}
if err := gob.NewDecoder(p).Decode(&payload); err != nil {
fmsg.Fatal("cannot decode init payload:", err)
} else {
// sharing stdout with parent
// USE WITH CAUTION
fmsg.SetVerbose(payload.Verbose)
// child does not need to see this
if err = os.Unsetenv(EnvInit); err != nil {
fmsg.Println("cannot unset", EnvInit+":", err)
// not fatal
} else {
fmsg.VPrintln("received configuration")
}
}
// close config fd
if err := p.Close(); err != nil {
fmsg.Println("cannot close config fd:", err)
// not fatal
}
// die with parent
if _, _, errno := syscall.RawSyscall(syscall.SYS_PRCTL, syscall.PR_SET_PDEATHSIG, uintptr(syscall.SIGKILL), 0); errno != 0 {
fmsg.Fatal("prctl(PR_SET_PDEATHSIG, SIGKILL):", errno.Error())
}
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()
// pass wayland fd
if payload.WL != -1 {
if f := os.NewFile(uintptr(payload.WL), "wayland"); f != nil {
cmd.Env = append(cmd.Env, "WAYLAND_SOCKET="+strconv.Itoa(3+len(cmd.ExtraFiles)))
cmd.ExtraFiles = append(cmd.ExtraFiles, f)
}
}
if err := cmd.Start(); err != nil {
fmsg.Fatalf("cannot start %q: %v", payload.Argv0, err)
}
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) {
fmsg.Println("unexpected wait4 response:", err)
}
close(done)
}()
timeout := make(chan struct{})
r := 2
for {
select {
case s := <-sig:
fmsg.VPrintln("received", s.String())
fmsg.Exit(0)
case w := <-info:
if w.wpid == cmd.Process.Pid {
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:
fmsg.Exit(r)
case <-timeout:
fmsg.Println("timeout exceeded waiting for lingering processes")
fmsg.Exit(r)
}
}
}
// Try runs init and stops execution if FORTIFY_INIT is set.
func Try() {
if os.Getpid() != 1 {
return
}
if args := flag.Args(); len(args) == 1 && args[0] == "init" {
if s, ok := os.LookupEnv(EnvInit); ok {
if fd, err := strconv.Atoi(s); err != nil {
fmsg.Fatalf("cannot parse %q: %v", s, err)
} else {
doInit(uintptr(fd))
}
panic("unreachable")
}
}
}

View File

@ -1,15 +0,0 @@
package init0
const EnvInit = "FORTIFY_INIT"
type Payload struct {
// target full exec path
Argv0 string
// child full argv
Argv []string
// wayland fd, -1 to disable
WL int
// verbosity pass through
Verbose bool
}

View File

@ -1,179 +0,0 @@
package shim
import (
"encoding/gob"
"errors"
"flag"
"net"
"os"
"path"
"strconv"
"syscall"
"git.ophivana.moe/security/fortify/helper"
"git.ophivana.moe/security/fortify/internal/fmsg"
init0 "git.ophivana.moe/security/fortify/internal/init"
)
// everything beyond this point runs as target user
// proceed with caution!
func doShim(socket string) {
fmsg.SetPrefix("shim")
// re-exec
if len(os.Args) > 0 && os.Args[0] != "fortify" && path.IsAbs(os.Args[0]) {
if err := syscall.Exec(os.Args[0], []string{"fortify", "shim"}, os.Environ()); err != nil {
fmsg.Println("cannot re-exec self:", err)
// continue anyway
}
}
// dial setup socket
var conn *net.UnixConn
if c, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: socket, Net: "unix"}); err != nil {
fmsg.Fatal("cannot dial setup socket:", err)
panic("unreachable")
} else {
conn = c
}
// decode payload gob stream
var payload Payload
if err := gob.NewDecoder(conn).Decode(&payload); err != nil {
fmsg.Fatal("cannot decode shim payload:", err)
} else {
// sharing stdout with parent
// USE WITH CAUTION
fmsg.SetVerbose(payload.Verbose)
}
if payload.Bwrap == nil {
fmsg.Fatal("bwrap config not supplied")
}
// receive wayland fd over socket
wfd := -1
if payload.WL {
if fd, err := receiveWLfd(conn); err != nil {
fmsg.Fatal("cannot receive wayland fd:", err)
} else {
wfd = fd
}
}
// close setup socket
if err := conn.Close(); err != nil {
fmsg.Println("cannot close setup socket:", err)
// not fatal
}
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[2]
} else {
// no argv, look up shell instead
var ok bool
if ic.Argv0, ok = os.LookupEnv("SHELL"); !ok {
fmsg.Fatal("no command was specified and $SHELL was unset")
}
ic.Argv = []string{ic.Argv0}
}
conf := payload.Bwrap
var extraFiles []*os.File
// pass wayland fd
if wfd != -1 {
if f := os.NewFile(uintptr(wfd), "wayland"); f != nil {
ic.WL = 3 + len(extraFiles)
extraFiles = append(extraFiles, f)
}
} else {
ic.WL = -1
}
// share config pipe
if r, w, err := os.Pipe(); err != nil {
fmsg.Fatal("cannot pipe:", err)
} else {
conf.SetEnv[init0.EnvInit] = strconv.Itoa(3 + len(extraFiles))
extraFiles = append(extraFiles, r)
fmsg.VPrintln("transmitting config to init")
go func() {
// stream config to pipe
if err = gob.NewEncoder(w).Encode(&ic); err != nil {
fmsg.Fatal("cannot transmit init config:", err)
}
}()
}
helper.BubblewrapName = payload.Exec[1] // resolved bwrap path by parent
if b, err := helper.NewBwrap(conf, nil, payload.Exec[0], func(int, int) []string { return []string{"init"} }); err != nil {
fmsg.Fatal("malformed sandbox config:", err)
} else {
cmd := b.Unwrap()
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.ExtraFiles = extraFiles
if fmsg.Verbose() {
fmsg.VPrintln("bwrap args:", conf.Args())
}
// run and pass through exit code
if err = b.Start(); err != nil {
fmsg.Fatal("cannot start target process:", err)
} else if err = b.Wait(); err != nil {
fmsg.VPrintln("wait:", err)
}
if b.Unwrap().ProcessState != nil {
fmsg.Exit(b.Unwrap().ProcessState.ExitCode())
} else {
fmsg.Exit(127)
}
}
}
func receiveWLfd(conn *net.UnixConn) (int, error) {
oob := make([]byte, syscall.CmsgSpace(4)) // single fd
if _, oobn, _, _, err := conn.ReadMsgUnix(nil, oob); err != nil {
return -1, err
} else if len(oob) != oobn {
return -1, errors.New("invalid message length")
}
var msg syscall.SocketControlMessage
if messages, err := syscall.ParseSocketControlMessage(oob); err != nil {
return -1, err
} else if len(messages) != 1 {
return -1, errors.New("unexpected message count")
} else {
msg = messages[0]
}
if fds, err := syscall.ParseUnixRights(&msg); err != nil {
return -1, err
} else if len(fds) != 1 {
return -1, errors.New("unexpected fd count")
} else {
return fds[0], nil
}
}
// Try runs shim and stops execution if FORTIFY_SHIM is set.
func Try() {
if args := flag.Args(); len(args) == 1 && args[0] == "shim" {
if s, ok := os.LookupEnv(EnvShim); ok {
doShim(s)
panic("unreachable")
}
}
}

View File

@ -1,200 +0,0 @@
package shim
import (
"errors"
"net"
"os"
"os/exec"
"sync"
"sync/atomic"
"syscall"
"time"
"git.ophivana.moe/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
// used by the parent process
type Shim struct {
// user switcher process
cmd *exec.Cmd
// uid of shim target user
uid uint32
// whether to check shim pid
checkPid bool
// user switcher executable path
executable string
// path to setup socket
socket string
// shim setup abort reason and completion
abort chan error
abortErr atomic.Pointer[error]
abortOnce sync.Once
// wayland mediation, nil if disabled
wl *Wayland
// shim setup payload
payload *Payload
}
func New(executable string, uid uint32, socket string, wl *Wayland, payload *Payload, checkPid bool) *Shim {
return &Shim{uid: uid, executable: executable, socket: socket, wl: wl, payload: payload, checkPid: checkPid}
}
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) Abort(err error) {
s.abortOnce.Do(func() {
s.abortErr.Store(&err)
// s.abort is buffered so this will never block
s.abort <- err
})
}
func (s *Shim) AbortWait(err error) {
s.Abort(err)
<-s.abort
}
type CommandBuilder func(shimEnv string) (args []string)
func (s *Shim) Start(f CommandBuilder) (*time.Time, error) {
var (
cf chan *net.UnixConn
accept func()
)
// listen on setup socket
if c, a, err := s.serve(); err != nil {
return nil, fmsg.WrapErrorSuffix(err,
"cannot listen on shim setup socket:")
} else {
// accepts a connection after each call to accept
// connections are sent to the channel cf
cf, accept = c, a
}
// start user switcher process and save time
s.cmd = exec.Command(s.executable, f(EnvShim+"="+s.socket)...)
s.cmd.Env = []string{}
s.cmd.Stdin, s.cmd.Stdout, s.cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
s.cmd.Dir = "/"
fmsg.VPrintln("starting shim via user switcher:", s.cmd)
fmsg.Withhold() // withhold messages to stderr
if err := s.cmd.Start(); err != nil {
return nil, fmsg.WrapErrorSuffix(err,
"cannot start user switcher:")
}
startTime := time.Now().UTC()
// kill shim if something goes wrong and an error is returned
killShim := func() {
if err := s.cmd.Process.Signal(os.Interrupt); err != nil {
fmsg.Println("cannot terminate shim on faulted setup:", err)
}
}
defer func() { killShim() }()
accept()
conn := <-cf
if conn == nil {
return &startTime, fmsg.WrapErrorSuffix(*s.abortErr.Load(), "cannot accept call on setup socket:")
}
// authenticate against called provided uid and shim pid
if cred, err := peerCred(conn); err != nil {
return &startTime, fmsg.WrapErrorSuffix(*s.abortErr.Load(), "cannot retrieve shim credentials:")
} else if cred.Uid != s.uid {
fmsg.Printf("process %d owned by user %d tried to connect, expecting %d",
cred.Pid, cred.Uid, s.uid)
err = errors.New("compromised fortify build")
s.Abort(err)
return &startTime, err
} else if s.checkPid && cred.Pid != int32(s.cmd.Process.Pid) {
fmsg.Printf("process %d tried to connect to shim setup socket, expecting shim %d",
cred.Pid, s.cmd.Process.Pid)
err = errors.New("compromised target user")
s.Abort(err)
return &startTime, err
}
// serve payload and wayland fd if enabled
// this also closes the connection
err := s.payload.serve(conn, s.wl)
if err == nil {
killShim = func() {}
}
s.Abort(err) // aborting with nil indicates success
return &startTime, err
}
func (s *Shim) serve() (chan *net.UnixConn, func(), error) {
if s.abort != nil {
panic("attempted to serve shim setup twice")
}
s.abort = make(chan error, 1)
cf := make(chan *net.UnixConn)
accept := make(chan struct{}, 1)
if l, err := net.ListenUnix("unix", &net.UnixAddr{Name: s.socket, Net: "unix"}); err != nil {
return nil, nil, err
} else {
l.SetUnlinkOnClose(true)
fmsg.VPrintf("listening on shim setup socket %q", s.socket)
if err = acl.UpdatePerm(s.socket, int(s.uid), acl.Read, acl.Write, acl.Execute); err != nil {
fmsg.Println("cannot append ACL entry to shim setup socket:", err)
s.Abort(err) // ensures setup socket cleanup
}
go func() {
for {
select {
case err = <-s.abort:
if err != nil {
fmsg.VPrintln("aborting shim setup, reason:", err)
}
if err = l.Close(); err != nil {
fmsg.Println("cannot close setup socket:", err)
}
close(s.abort)
close(cf)
return
case <-accept:
if conn, err0 := l.AcceptUnix(); err0 != nil {
s.Abort(err0) // does not block, breaks loop
cf <- nil // receiver sees nil value and loads err0 stored during abort
} else {
cf <- conn
}
}
}
}()
}
return cf, func() { accept <- struct{}{} }, nil
}
// peerCred fetches peer credentials of conn
func peerCred(conn *net.UnixConn) (ucred *syscall.Ucred, err error) {
var raw syscall.RawConn
if raw, err = conn.SyscallConn(); err != nil {
return
}
err0 := raw.Control(func(fd uintptr) {
ucred, err = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
})
err = errors.Join(err, err0)
return
}

View File

@ -1,42 +0,0 @@
package shim
import (
"encoding/gob"
"errors"
"net"
"git.ophivana.moe/security/fortify/helper/bwrap"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
const EnvShim = "FORTIFY_SHIM"
type Payload struct {
// child full argv
Argv []string
// fortify, bwrap, target full exec path
Exec [3]string
// bwrap config
Bwrap *bwrap.Config
// whether to pass wayland fd
WL bool
// verbosity pass through
Verbose bool
}
func (p *Payload) serve(conn *net.UnixConn, wl *Wayland) error {
if err := gob.NewEncoder(conn).Encode(*p); err != nil {
return fmsg.WrapErrorSuffix(err,
"cannot stream shim payload:")
}
if wl != nil {
if err := wl.WriteUnix(conn); err != nil {
return errors.Join(err, conn.Close())
}
}
return fmsg.WrapErrorSuffix(conn.Close(),
"cannot close setup connection:")
}

View File

@ -1,75 +0,0 @@
package shim
import (
"fmt"
"net"
"sync"
"syscall"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
// Wayland implements wayland mediation.
type Wayland struct {
// wayland socket path
Path string
// wayland connection
conn *net.UnixConn
connErr error
sync.Once
// wait for wayland client to exit
done chan struct{}
}
func (wl *Wayland) WriteUnix(conn *net.UnixConn) error {
// connect to host wayland socket
if f, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: wl.Path, Net: "unix"}); err != nil {
return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot connect to wayland at %q:", wl.Path))
} else {
fmsg.VPrintf("connected to wayland at %q", wl.Path)
wl.conn = f
}
// set up for passing wayland socket
if rc, err := wl.conn.SyscallConn(); err != nil {
return fmsg.WrapErrorSuffix(err, "cannot obtain raw wayland connection:")
} else {
ec := make(chan error)
go func() {
// pass wayland connection fd
if err = rc.Control(func(fd uintptr) {
if _, _, err = conn.WriteMsgUnix(nil, syscall.UnixRights(int(fd)), nil); err != nil {
ec <- fmsg.WrapErrorSuffix(err, "cannot pass wayland connection to shim:")
return
}
ec <- nil
// block until shim exits
<-wl.done
fmsg.VPrintln("releasing wayland connection")
}); err != nil {
ec <- fmsg.WrapErrorSuffix(err, "cannot obtain wayland connection fd:")
return
}
}()
return <-ec
}
}
func (wl *Wayland) Close() error {
wl.Do(func() {
close(wl.done)
wl.connErr = wl.conn.Close()
})
return wl.connErr
}
func NewWayland() *Wayland {
wl := new(Wayland)
wl.done = make(chan struct{})
return wl
}

View File

@ -1,4 +1,4 @@
package system package state
type ( type (
// Enablement represents an optional system resource // Enablement represents an optional system resource
@ -8,25 +8,22 @@ type (
) )
const ( const (
EWayland Enablement = iota EnableWayland Enablement = iota
EX11 EnableX
EDBus EnableDBus
EPulse EnablePulse
EnableLength
) )
var enablementString = [...]string{ var enablementString = [EnableLength]string{
EWayland: "Wayland", "Wayland",
EX11: "X11", "X11",
EDBus: "D-Bus", "D-Bus",
EPulse: "PulseAudio", "PulseAudio",
} }
const ELen = len(enablementString)
func (e Enablement) String() string { func (e Enablement) String() string {
if int(e) >= ELen {
return "<invalid enablement>"
}
return enablementString[e] return enablementString[e]
} }

View File

@ -1,7 +1,6 @@
package state package state
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path" "path"
@ -10,41 +9,43 @@ import (
"text/tabwriter" "text/tabwriter"
"time" "time"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/security/fortify/internal/system" "git.ophivana.moe/cat/fortify/internal/verbose"
) )
// MustPrintLauncherStateSimpleGlobal prints active launcher states of all simple stores // MustPrintLauncherStateSimpleGlobal prints active launcher states of all simple stores
// in an implementation-specific way. // in an implementation-specific way.
func MustPrintLauncherStateSimpleGlobal(w **tabwriter.Writer, runDir string) { func MustPrintLauncherStateSimpleGlobal(w **tabwriter.Writer) {
sc := internal.GetSC()
now := time.Now().UTC() now := time.Now().UTC()
// read runtime directory to get all UIDs // read runtime directory to get all UIDs
if dirs, err := os.ReadDir(path.Join(runDir, "state")); err != nil && !errors.Is(err, os.ErrNotExist) { if dirs, err := os.ReadDir(sc.RunDirPath); err != nil {
fmsg.Fatal("cannot read runtime directory:", err) fmt.Println("cannot read runtime directory:", err)
os.Exit(1)
} else { } else {
for _, e := range dirs { for _, e := range dirs {
// skip non-directories // skip non-directories
if !e.IsDir() { if !e.IsDir() {
fmsg.VPrintf("skipped non-directory entry %q", e.Name()) verbose.Println("skipped non-directory entry", e.Name())
continue continue
} }
// skip non-numerical names // skip non-numerical names
if _, err = strconv.Atoi(e.Name()); err != nil { if _, err = strconv.Atoi(e.Name()); err != nil {
fmsg.VPrintf("skipped non-uid entry %q", e.Name()) verbose.Println("skipped non-uid entry", e.Name())
continue continue
} }
// obtain temporary store // obtain temporary store
s := NewSimple(runDir, e.Name()).(*simpleStore) s := NewSimple(sc.RunDirPath, e.Name()).(*simpleStore)
// print states belonging to this store // print states belonging to this store
s.mustPrintLauncherState(w, now) s.mustPrintLauncherState(w, now)
// mustPrintLauncherState causes store activity so store needs to be closed // mustPrintLauncherState causes store activity so store needs to be closed
if err = s.Close(); err != nil { if err = s.Close(); err != nil {
fmsg.Printf("cannot close store for user %q: %s", e.Name(), err) fmt.Printf("warn: error closing store for user %s: %s\n", e.Name(), err)
} }
} }
} }
@ -66,8 +67,8 @@ func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time
*w = tabwriter.NewWriter(os.Stdout, 0, 1, 4, ' ', 0) *w = tabwriter.NewWriter(os.Stdout, 0, 1, 4, ' ', 0)
// write header when initialising // write header when initialising
if !fmsg.Verbose() { if !verbose.Get() {
_, _ = fmt.Fprintln(*w, "\tUID\tPID\tUptime\tEnablements\tMethod\tCommand") _, _ = fmt.Fprintln(*w, "\tUID\tPID\tUptime\tEnablements\tLauncher\tCommand")
} else { } else {
// argv is emitted in body when verbose // argv is emitted in body when verbose
_, _ = fmt.Fprintln(*w, "\tUID\tPID\tArgv") _, _ = fmt.Fprintln(*w, "\tUID\tPID\tArgv")
@ -85,7 +86,7 @@ func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time
// build enablements string // build enablements string
ets := strings.Builder{} ets := strings.Builder{}
// append enablement strings in order // append enablement strings in order
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ { for i := Enablement(0); i < EnableLength; i++ {
if state.Capability.Has(i) { if state.Capability.Has(i) {
ets.WriteString(", " + i.String()) ets.WriteString(", " + i.String())
} }
@ -95,9 +96,9 @@ func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time
ets.WriteString("(No enablements)") ets.WriteString("(No enablements)")
} }
if !fmsg.Verbose() { if !verbose.Get() {
_, _ = fmt.Fprintf(*w, "\t%s\t%d\t%s\t%s\t%s\t%s\n", _, _ = fmt.Fprintf(*w, "\t%s\t%d\t%s\t%s\t%s\t%s\n",
s.path[len(s.path)-1], state.PID, now.Sub(state.Time).Round(time.Second).String(), strings.TrimPrefix(ets.String(), ", "), state.Method, s.path[len(s.path)-1], state.PID, now.Sub(state.Time).Round(time.Second).String(), strings.TrimPrefix(ets.String(), ", "), state.Launcher,
state.Command) state.Command)
} else { } else {
// emit argv instead when verbose // emit argv instead when verbose
@ -109,13 +110,15 @@ func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time
return nil return nil
}() }()
}); err != nil { }); err != nil {
fmsg.Printf("cannot perform action on store %q: %s", path.Join(s.path...), err) fmt.Printf("cannot perform action on store '%s': %s\n", path.Join(s.path...), err)
if !ok { if !ok {
fmsg.Fatal("store faulted before printing") fmt.Println("warn: store faulted before printing")
os.Exit(1)
} }
} }
if innerErr != nil { if innerErr != nil {
fmsg.Fatalf("cannot print launcher state for store %q: %s", path.Join(s.path...), innerErr) fmt.Printf("cannot print launcher state for store '%s': %s\n", path.Join(s.path...), innerErr)
os.Exit(1)
} }
} }

View File

@ -211,8 +211,9 @@ func (b *simpleBackend) Len() (int, error) {
} }
// NewSimple returns an instance of a file-based store. // NewSimple returns an instance of a file-based store.
func NewSimple(runDir string, prefix ...string) Store { // Store prefix is typically (runDir, uid).
func NewSimple(prefix ...string) Store {
b := new(simpleStore) b := new(simpleStore)
b.path = append([]string{runDir, "state"}, prefix...) b.path = prefix
return b return b
} }

View File

@ -2,8 +2,6 @@ package state
import ( import (
"time" "time"
"git.ophivana.moe/security/fortify/internal/system"
) )
type Store interface { type Store interface {
@ -31,10 +29,10 @@ type State struct {
// command used to seal the app // command used to seal the app
Command []string Command []string
// capability enablements applied to child // capability enablements applied to child
Capability system.Enablements Capability Enablements
// user switch method // resolved launcher path
Method string Launcher string
// full argv whe launching // full argv whe launching
Argv []string Argv []string
// process start time // process start time

View File

@ -1,126 +0,0 @@
package internal
import (
"errors"
"io/fs"
"os"
"os/exec"
"os/user"
"path"
"strconv"
"sync"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
// System provides safe access to operating system resources.
type System interface {
// Geteuid provides [os.Geteuid].
Geteuid() int
// LookupEnv provides [os.LookupEnv].
LookupEnv(key string) (string, bool)
// TempDir provides [os.TempDir].
TempDir() string
// LookPath provides [exec.LookPath].
LookPath(file string) (string, error)
// Executable provides [os.Executable].
Executable() (string, error)
// Lookup provides [user.Lookup].
Lookup(username string) (*user.User, error)
// ReadDir provides [os.ReadDir].
ReadDir(name string) ([]fs.DirEntry, error)
// Stat provides [os.Stat].
Stat(name string) (fs.FileInfo, error)
// Open provides [os.Open]
Open(name string) (fs.File, error)
// Exit provides [os.Exit].
Exit(code int)
// Paths returns a populated [Paths] struct.
Paths() Paths
// SdBooted implements https://www.freedesktop.org/software/systemd/man/sd_booted.html
SdBooted() bool
}
// Paths contains environment dependent paths used by fortify.
type Paths struct {
// path to shared directory e.g. /tmp/fortify.%d
SharePath string `json:"share_path"`
// XDG_RUNTIME_DIR value e.g. /run/user/%d
RuntimePath string `json:"runtime_path"`
// application runtime directory e.g. /run/user/%d/fortify
RunDirPath string `json:"run_dir_path"`
}
// CopyPaths is a generic implementation of [System.Paths].
func CopyPaths(os System, v *Paths) {
v.SharePath = path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Geteuid()))
fmsg.VPrintf("process share directory at %q", v.SharePath)
if r, ok := os.LookupEnv(xdgRuntimeDir); !ok || r == "" || !path.IsAbs(r) {
// fall back to path in share since fortify has no hard XDG dependency
v.RunDirPath = path.Join(v.SharePath, "run")
v.RuntimePath = path.Join(v.RunDirPath, "compat")
} else {
v.RuntimePath = r
v.RunDirPath = path.Join(v.RuntimePath, "fortify")
}
fmsg.VPrintf("runtime directory at %q", v.RunDirPath)
}
// Std implements System using the standard library.
type Std struct {
paths Paths
pathsOnce sync.Once
sdBooted bool
sdBootedOnce sync.Once
}
func (s *Std) Geteuid() int { return os.Geteuid() }
func (s *Std) LookupEnv(key string) (string, bool) { return os.LookupEnv(key) }
func (s *Std) TempDir() string { return os.TempDir() }
func (s *Std) LookPath(file string) (string, error) { return exec.LookPath(file) }
func (s *Std) Executable() (string, error) { return os.Executable() }
func (s *Std) Lookup(username string) (*user.User, error) { return user.Lookup(username) }
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) Open(name string) (fs.File, error) { return os.Open(name) }
func (s *Std) Exit(code int) { fmsg.Exit(code) }
const xdgRuntimeDir = "XDG_RUNTIME_DIR"
func (s *Std) Paths() Paths {
s.pathsOnce.Do(func() { CopyPaths(s, &s.paths) })
return s.paths
}
func (s *Std) SdBooted() bool {
s.sdBootedOnce.Do(func() { s.sdBooted = copySdBooted() })
return s.sdBooted
}
const systemdCheckPath = "/run/systemd/system"
func copySdBooted() bool {
if v, err := sdBooted(); err != nil {
fmsg.Println("cannot read systemd marker:", err)
return false
} else {
return v
}
}
func sdBooted() (bool, error) {
_, err := os.Stat(systemdCheckPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
err = nil
}
return false, err
}
return true, nil
}

View File

@ -1,70 +0,0 @@
package system
import (
"fmt"
"slices"
"git.ophivana.moe/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
// UpdatePerm appends an ephemeral acl update Op.
func (sys *I) UpdatePerm(path string, perms ...acl.Perm) *I {
sys.UpdatePermType(Process, path, perms...)
return sys
}
// UpdatePermType appends an acl update Op.
func (sys *I) UpdatePermType(et Enablement, path string, perms ...acl.Perm) *I {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, &ACL{et, path, perms})
return sys
}
type ACL struct {
et Enablement
path string
perms acl.Perms
}
func (a *ACL) Type() Enablement {
return a.et
}
func (a *ACL) apply(sys *I) error {
fmsg.VPrintln("applying ACL", a)
return fmsg.WrapErrorSuffix(acl.UpdatePerm(a.path, sys.uid, a.perms...),
fmt.Sprintf("cannot apply ACL entry to %q:", a.path))
}
func (a *ACL) revert(sys *I, ec *Criteria) error {
if ec.hasType(a) {
fmsg.VPrintln("stripping ACL", a)
return fmsg.WrapErrorSuffix(acl.UpdatePerm(a.path, sys.uid),
fmt.Sprintf("cannot strip ACL entry from %q:", a.path))
} else {
fmsg.VPrintln("skipping ACL", a)
return nil
}
}
func (a *ACL) Is(o Op) bool {
a0, ok := o.(*ACL)
return ok && a0 != nil &&
a.et == a0.et &&
a.path == a0.path &&
slices.Equal(a.perms, a0.perms)
}
func (a *ACL) Path() string {
return a.path
}
func (a *ACL) String() string {
return fmt.Sprintf("%s type: %s path: %q",
a.perms, TypeString(a.et), a.path)
}

View File

@ -1,90 +0,0 @@
package system
import (
"testing"
"git.ophivana.moe/security/fortify/acl"
)
func TestUpdatePerm(t *testing.T) {
testCases := []struct {
path string
perms []acl.Perm
}{
{"/run/user/1971/fortify", []acl.Perm{acl.Execute}},
{"/tmp/fortify.1971/tmpdir/150", []acl.Perm{acl.Read, acl.Write, acl.Execute}},
}
for _, tc := range testCases {
t.Run(tc.path+permSubTestSuffix(tc.perms), func(t *testing.T) {
sys := New(150)
sys.UpdatePerm(tc.path, tc.perms...)
(&tcOp{Process, tc.path}).test(t, sys.ops, []Op{&ACL{Process, tc.path, tc.perms}}, "UpdatePerm")
})
}
}
func TestUpdatePermType(t *testing.T) {
testCases := []struct {
perms []acl.Perm
tcOp
}{
{[]acl.Perm{acl.Execute}, tcOp{User, "/tmp/fortify.1971/tmpdir"}},
{[]acl.Perm{acl.Read, acl.Write, acl.Execute}, tcOp{User, "/tmp/fortify.1971/tmpdir/150"}},
{[]acl.Perm{acl.Execute}, tcOp{Process, "/run/user/1971/fortify/fcb8a12f7c482d183ade8288c3de78b5"}},
{[]acl.Perm{acl.Read}, tcOp{Process, "/tmp/fortify.1971/fcb8a12f7c482d183ade8288c3de78b5/passwd"}},
{[]acl.Perm{acl.Read}, tcOp{Process, "/tmp/fortify.1971/fcb8a12f7c482d183ade8288c3de78b5/group"}},
{[]acl.Perm{acl.Read, acl.Write, acl.Execute}, tcOp{EWayland, "/run/user/1971/wayland-0"}},
}
for _, tc := range testCases {
t.Run(tc.path+"_"+TypeString(tc.et)+permSubTestSuffix(tc.perms), func(t *testing.T) {
sys := New(150)
sys.UpdatePermType(tc.et, tc.path, tc.perms...)
tc.test(t, sys.ops, []Op{&ACL{tc.et, tc.path, tc.perms}}, "UpdatePermType")
})
}
}
func TestACL_String(t *testing.T) {
testCases := []struct {
want string
et Enablement
perms []acl.Perm
}{
{`--- type: Process path: "/nonexistent"`, Process, []acl.Perm{}},
{`r-- type: User path: "/nonexistent"`, User, []acl.Perm{acl.Read}},
{`-w- type: Wayland path: "/nonexistent"`, EWayland, []acl.Perm{acl.Write}},
{`--x type: X11 path: "/nonexistent"`, EX11, []acl.Perm{acl.Execute}},
{`rw- type: D-Bus path: "/nonexistent"`, EDBus, []acl.Perm{acl.Read, acl.Write}},
{`r-x type: PulseAudio path: "/nonexistent"`, EPulse, []acl.Perm{acl.Read, acl.Execute}},
{`rwx type: User path: "/nonexistent"`, User, []acl.Perm{acl.Read, acl.Write, acl.Execute}},
{`rwx type: Process path: "/nonexistent"`, Process, []acl.Perm{acl.Read, acl.Write, acl.Write, acl.Execute}},
}
for _, tc := range testCases {
t.Run(tc.want, func(t *testing.T) {
a := &ACL{et: tc.et, perms: tc.perms, path: "/nonexistent"}
if got := a.String(); got != tc.want {
t.Errorf("String() = %v, want %v",
got, tc.want)
}
})
}
}
func permSubTestSuffix(perms []acl.Perm) (suffix string) {
for _, perm := range perms {
switch perm {
case acl.Read:
suffix += "_read"
case acl.Write:
suffix += "_write"
case acl.Execute:
suffix += "_execute"
default:
panic("unreachable")
}
}
return
}

View File

@ -1,166 +0,0 @@
package system
import (
"errors"
"os"
"git.ophivana.moe/security/fortify/dbus"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
var (
ErrDBusConfig = errors.New("dbus config not supplied")
)
func (sys *I) MustProxyDBus(sessionPath string, session *dbus.Config, systemPath string, system *dbus.Config) *I {
if err := sys.ProxyDBus(session, system, sessionPath, systemPath); err != nil {
panic(err.Error())
} else {
return sys
}
}
func (sys *I) ProxyDBus(session, system *dbus.Config, sessionPath, systemPath string) error {
d := new(DBus)
// used by waiting goroutine to notify process exit
d.done = make(chan struct{})
// session bus is mandatory
if session == nil {
return fmsg.WrapError(ErrDBusConfig,
"attempted to seal message bus proxy without session bus config")
}
// system bus is optional
d.system = system == nil
// upstream address, downstream socket path
var sessionBus, systemBus [2]string
// resolve upstream bus addresses
sessionBus[0], systemBus[0] = dbus.Address()
// set paths from caller
sessionBus[1], systemBus[1] = sessionPath, systemPath
// create proxy instance
d.proxy = dbus.New(sessionBus, systemBus)
defer func() {
if fmsg.Verbose() && d.proxy.Sealed() {
fmsg.VPrintln("sealed session proxy", session.Args(sessionBus))
if system != nil {
fmsg.VPrintln("sealed system proxy", system.Args(systemBus))
}
fmsg.VPrintln("message bus proxy final args:", d.proxy)
}
}()
// queue operation
sys.ops = append(sys.ops, d)
// seal dbus proxy
return fmsg.WrapErrorSuffix(d.proxy.Seal(session, system),
"cannot seal message bus proxy:")
}
type DBus struct {
proxy *dbus.Proxy
// whether system bus proxy is enabled
system bool
// notification from goroutine waiting for dbus.Proxy
done chan struct{}
}
func (d *DBus) Type() Enablement {
return Process
}
func (d *DBus) apply(_ *I) error {
fmsg.VPrintf("session bus proxy on %q for upstream %q", d.proxy.Session()[1], d.proxy.Session()[0])
if d.system {
fmsg.VPrintf("system bus proxy on %q for upstream %q", d.proxy.System()[1], d.proxy.System()[0])
}
// ready channel passed to dbus package
ready := make(chan error, 1)
// background dbus proxy start
if err := d.proxy.Start(ready, os.Stderr, true); err != nil {
return fmsg.WrapErrorSuffix(err,
"cannot start message bus proxy:")
}
fmsg.VPrintln("starting message bus proxy:", d.proxy)
if fmsg.Verbose() { // save the extra bwrap arg build when verbose logging is off
fmsg.VPrintln("message bus proxy bwrap args:", d.proxy.Bwrap())
}
// background wait for proxy instance and notify completion
go func() {
if err := d.proxy.Wait(); err != nil {
fmsg.Println("message bus proxy exited with error:", err)
go func() { ready <- err }()
} else {
fmsg.VPrintln("message bus proxy exit")
}
// ensure socket removal so ephemeral directory is empty at revert
if err := os.Remove(d.proxy.Session()[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
fmsg.Println("cannot remove dangling session bus socket:", err)
}
if d.system {
if err := os.Remove(d.proxy.System()[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
fmsg.Println("cannot remove dangling system bus socket:", err)
}
}
// notify proxy completion
close(d.done)
}()
// ready is not nil if the proxy process faulted
if err := <-ready; err != nil {
// note that err here is either an I/O error or a predetermined unexpected behaviour error
return fmsg.WrapErrorSuffix(err,
"message bus proxy fault after start:")
}
fmsg.VPrintln("message bus proxy ready")
return nil
}
func (d *DBus) revert(_ *I, _ *Criteria) error {
// criteria ignored here since dbus is always process-scoped
fmsg.VPrintln("terminating message bus proxy")
if err := d.proxy.Close(); err != nil {
if errors.Is(err, os.ErrClosed) {
return fmsg.WrapError(err,
"message bus proxy already closed")
} else {
return fmsg.WrapErrorSuffix(err,
"cannot stop message bus proxy:")
}
}
// block until proxy wait returns
<-d.done
return nil
}
func (d *DBus) Is(o Op) bool {
d0, ok := o.(*DBus)
return ok && d0 != nil &&
((d.proxy == nil && d0.proxy == nil) ||
(d.proxy != nil && d0.proxy != nil && d.proxy.String() == d0.proxy.String()))
}
func (d *DBus) Path() string {
return "(dbus proxy)"
}
func (d *DBus) String() string {
return d.proxy.String()
}

View File

@ -1,88 +0,0 @@
package system
import (
"errors"
"fmt"
"os"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
// Ensure the existence and mode of a directory.
func (sys *I) Ensure(name string, perm os.FileMode) *I {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, &Mkdir{User, name, perm, false})
return sys
}
// Ephemeral ensures the temporary existence and mode of a directory through the life of et.
func (sys *I) Ephemeral(et Enablement, name string, perm os.FileMode) *I {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, &Mkdir{et, name, perm, true})
return sys
}
type Mkdir struct {
et Enablement
path string
perm os.FileMode
ephemeral bool
}
func (m *Mkdir) Type() Enablement {
return m.et
}
func (m *Mkdir) apply(_ *I) error {
fmsg.VPrintln("ensuring directory", m)
// create directory
err := os.Mkdir(m.path, m.perm)
if !errors.Is(err, os.ErrExist) {
return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot create directory %q:", m.path))
}
// directory exists, ensure mode
return fmsg.WrapErrorSuffix(os.Chmod(m.path, m.perm),
fmt.Sprintf("cannot change mode of %q to %s:", m.path, m.perm))
}
func (m *Mkdir) revert(_ *I, ec *Criteria) error {
if !m.ephemeral {
// skip non-ephemeral dir and do not log anything
return nil
}
if ec.hasType(m) {
fmsg.VPrintln("destroying ephemeral directory", m)
return fmsg.WrapErrorSuffix(os.Remove(m.path),
fmt.Sprintf("cannot remove ephemeral directory %q:", m.path))
} else {
fmsg.VPrintln("skipping ephemeral directory", m)
return nil
}
}
func (m *Mkdir) Is(o Op) bool {
m0, ok := o.(*Mkdir)
return ok && m0 != nil && *m == *m0
}
func (m *Mkdir) Path() string {
return m.path
}
func (m *Mkdir) String() string {
t := "Ensure"
if m.ephemeral {
t = TypeString(m.Type())
}
return fmt.Sprintf("mode: %s type: %s path: %q", m.perm.String(), t, m.path)
}

View File

@ -1,73 +0,0 @@
package system
import (
"os"
"testing"
)
func TestEnsure(t *testing.T) {
testCases := []struct {
name string
perm os.FileMode
}{
{"/tmp/fortify.1971", 0701},
{"/tmp/fortify.1971/tmpdir", 0700},
{"/tmp/fortify.1971/tmpdir/150", 0700},
{"/run/user/1971/fortify", 0700},
}
for _, tc := range testCases {
t.Run(tc.name+"_"+tc.perm.String(), func(t *testing.T) {
sys := New(150)
sys.Ensure(tc.name, tc.perm)
(&tcOp{User, tc.name}).test(t, sys.ops, []Op{&Mkdir{User, tc.name, tc.perm, false}}, "Ensure")
})
}
}
func TestEphemeral(t *testing.T) {
testCases := []struct {
perm os.FileMode
tcOp
}{
{0700, tcOp{Process, "/run/user/1971/fortify/ec07546a772a07cde87389afc84ffd13"}},
{0701, tcOp{Process, "/tmp/fortify.1971/ec07546a772a07cde87389afc84ffd13"}},
}
for _, tc := range testCases {
t.Run(tc.path+"_"+tc.perm.String()+"_"+TypeString(tc.et), func(t *testing.T) {
sys := New(150)
sys.Ephemeral(tc.et, tc.path, tc.perm)
tc.test(t, sys.ops, []Op{&Mkdir{tc.et, tc.path, tc.perm, true}}, "Ephemeral")
})
}
}
func TestMkdir_String(t *testing.T) {
testCases := []struct {
want string
ephemeral bool
et Enablement
}{
{"Ensure", false, User},
{"Ensure", false, Process},
{"Ensure", false, EWayland},
{"Wayland", true, EWayland},
{"X11", true, EX11},
{"D-Bus", true, EDBus},
{"PulseAudio", true, EPulse},
}
for _, tc := range testCases {
t.Run(tc.want, func(t *testing.T) {
m := &Mkdir{
et: tc.et,
path: "/nonexistent",
perm: 0701,
ephemeral: tc.ephemeral,
}
want := "mode: " + os.FileMode(0701).String() + " type: " + tc.want + " path: \"/nonexistent\""
if got := m.String(); got != want {
t.Errorf("String() = %v, want %v", got, want)
}
})
}
}

View File

@ -1,140 +0,0 @@
package system
import (
"errors"
"sync"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
const (
// User type is reverted at final launcher exit.
User = Enablement(ELen)
// Process type is unconditionally reverted on exit.
Process = Enablement(ELen + 1)
)
type Criteria struct {
*Enablements
}
func (ec *Criteria) hasType(o Op) bool {
// nil criteria: revert everything except User
if ec.Enablements == nil {
return o.Type() != User
}
return ec.Has(o.Type())
}
// Op is a reversible system operation.
type Op interface {
// Type returns Op's enablement type.
Type() Enablement
// apply the Op
apply(sys *I) error
// revert reverses the Op if criteria is met
revert(sys *I, ec *Criteria) error
Is(o Op) bool
Path() string
String() string
}
func TypeString(e Enablement) string {
switch e {
case User:
return "User"
case Process:
return "Process"
default:
return e.String()
}
}
type I struct {
uid int
ops []Op
state [2]bool
lock sync.Mutex
}
func (sys *I) UID() int {
return sys.uid
}
func (sys *I) Equal(v *I) bool {
if v == nil || sys.uid != v.uid || len(sys.ops) != len(v.ops) {
return false
}
for i, o := range sys.ops {
if !o.Is(v.ops[i]) {
return false
}
}
return true
}
func (sys *I) Commit() error {
sys.lock.Lock()
defer sys.lock.Unlock()
if sys.state[0] {
panic("sys instance committed twice")
}
sys.state[0] = true
sp := New(sys.uid)
sp.ops = make([]Op, 0, len(sys.ops)) // prevent copies during commits
defer func() {
// sp is set to nil when all ops are applied
if sp != nil {
// rollback partial commit
fmsg.VPrintf("commit faulted after %d ops, rolling back partial commit", len(sp.ops))
if err := sp.Revert(&Criteria{nil}); err != nil {
fmsg.Println("errors returned reverting partial commit:", err)
}
}
}()
for _, o := range sys.ops {
if err := o.apply(sys); err != nil {
return err
} else {
// register partial commit
sp.ops = append(sp.ops, o)
}
}
// disarm partial commit rollback
sp = nil
return nil
}
func (sys *I) Revert(ec *Criteria) error {
sys.lock.Lock()
defer sys.lock.Unlock()
if sys.state[1] {
panic("sys instance reverted twice")
}
sys.state[1] = true
// collect errors
errs := make([]error, len(sys.ops))
for i := range sys.ops {
errs[i] = sys.ops[len(sys.ops)-i-1].revert(sys, ec)
}
// errors.Join filters nils
return errors.Join(errs...)
}
func New(uid int) *I {
return &I{uid: uid}
}

View File

@ -1,79 +0,0 @@
package system
import "testing"
type tcOp struct {
et Enablement
path string
}
// test an instance of the Op interface
func (ptc tcOp) test(t *testing.T, gotOps []Op, wantOps []Op, fn string) {
if len(gotOps) != len(wantOps) {
t.Errorf("%s: inserted %v Ops, want %v", fn,
len(gotOps), len(wantOps))
return
}
t.Run("path", func(t *testing.T) {
if len(gotOps) > 0 {
if got := gotOps[0].Path(); got != ptc.path {
t.Errorf("Path() = %q, want %q",
got, ptc.path)
return
}
}
})
for i := range gotOps {
o := gotOps[i]
t.Run("is", func(t *testing.T) {
if !o.Is(o) {
t.Errorf("Is returned false on self")
return
}
if !o.Is(wantOps[i]) {
t.Errorf("%s: inserted %#v, want %#v",
fn,
o, wantOps[i])
return
}
})
t.Run("criteria", func(t *testing.T) {
testCases := []struct {
name string
ec *Criteria
want bool
}{
{"nil", newCriteria(), ptc.et != User},
{"self", newCriteria(ptc.et), true},
{"all", newCriteria(EWayland, EX11, EDBus, EPulse, User, Process), true},
{"enablements", newCriteria(EWayland, EX11, EDBus, EPulse), ptc.et != User && ptc.et != Process},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.ec.hasType(o); got != tc.want {
t.Errorf("hasType: got %v, want %v",
got, tc.want)
}
})
}
})
}
}
func newCriteria(labels ...Enablement) *Criteria {
ec := new(Criteria)
if len(labels) == 0 {
return ec
}
ec.Enablements = new(Enablements)
for _, e := range labels {
ec.Set(e)
}
return ec
}

View File

@ -1,129 +0,0 @@
package system_test
import (
"strconv"
"testing"
"git.ophivana.moe/security/fortify/internal/system"
)
func TestNew(t *testing.T) {
testCases := []struct {
uid int
}{
{150},
{149},
{148},
{147},
}
for _, tc := range testCases {
t.Run("sys initialised with uid "+strconv.Itoa(tc.uid), func(t *testing.T) {
if got := system.New(tc.uid); got.UID() != tc.uid {
t.Errorf("New(%d) uid = %d, want %d",
tc.uid,
got.UID(), tc.uid)
}
})
}
}
func TestTypeString(t *testing.T) {
testCases := []struct {
e system.Enablement
want string
}{
{system.EWayland, system.EWayland.String()},
{system.EX11, system.EX11.String()},
{system.EDBus, system.EDBus.String()},
{system.EPulse, system.EPulse.String()},
{system.User, "User"},
{system.Process, "Process"},
}
for _, tc := range testCases {
t.Run("label type string "+tc.want, func(t *testing.T) {
if got := system.TypeString(tc.e); got != tc.want {
t.Errorf("TypeString(%d) = %v, want %v",
tc.e,
got, tc.want)
}
})
}
}
func TestI_Equal(t *testing.T) {
testCases := []struct {
name string
sys *system.I
v *system.I
want bool
}{
{
"simple UID",
system.New(150),
system.New(150),
true,
},
{
"simple UID differ",
system.New(150),
system.New(151),
false,
},
{
"simple UID nil",
system.New(150),
nil,
false,
},
{
"op length mismatch",
system.New(150).
ChangeHosts("chronos"),
system.New(150).
ChangeHosts("chronos").
Ensure("/run", 0755),
false,
},
{
"op value mismatch",
system.New(150).
ChangeHosts("chronos").
Ensure("/run", 0644),
system.New(150).
ChangeHosts("chronos").
Ensure("/run", 0755),
false,
},
{
"op type mismatch",
system.New(150).
ChangeHosts("chronos").
CopyFile("/tmp/fortify.1971/30c9543e0a2c9621a8bfecb9d874c347/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"),
system.New(150).
ChangeHosts("chronos").
Ensure("/run", 0755),
false,
},
{
"op equals",
system.New(150).
ChangeHosts("chronos").
Ensure("/run", 0755),
system.New(150).
ChangeHosts("chronos").
Ensure("/run", 0755),
true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if tc.sys.Equal(tc.v) != tc.want {
t.Errorf("Equal: got %v; want %v",
!tc.want, tc.want)
}
})
}
}

View File

@ -1,145 +0,0 @@
package system
import (
"errors"
"fmt"
"io"
"os"
"strconv"
"git.ophivana.moe/security/fortify/acl"
"git.ophivana.moe/security/fortify/internal/fmsg"
)
// CopyFile registers an Op that copies path dst from src.
func (sys *I) CopyFile(dst, src string) *I {
return sys.CopyFileType(Process, dst, src)
}
// CopyFileType registers a file copying Op labelled with type et.
func (sys *I) CopyFileType(et Enablement, dst, src string) *I {
sys.lock.Lock()
sys.ops = append(sys.ops, &Tmpfile{et, tmpfileCopy, dst, src})
sys.lock.Unlock()
sys.UpdatePermType(et, dst, acl.Read)
return sys
}
// Link registers an Op that links dst to src.
func (sys *I) Link(oldname, newname string) *I {
return sys.LinkFileType(Process, oldname, newname)
}
// LinkFileType registers a file linking Op labelled with type et.
func (sys *I) LinkFileType(et Enablement, oldname, newname string) *I {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, &Tmpfile{et, tmpfileLink, newname, oldname})
return sys
}
// Write registers an Op that writes dst with the contents of src.
func (sys *I) Write(dst, src string) *I {
return sys.WriteType(Process, dst, src)
}
// WriteType registers a file writing Op labelled with type et.
func (sys *I) WriteType(et Enablement, dst, src string) *I {
sys.lock.Lock()
sys.ops = append(sys.ops, &Tmpfile{et, tmpfileWrite, dst, src})
sys.lock.Unlock()
sys.UpdatePermType(et, dst, acl.Read)
return sys
}
const (
tmpfileCopy uint8 = iota
tmpfileLink
tmpfileWrite
)
type Tmpfile struct {
et Enablement
method uint8
dst, src string
}
func (t *Tmpfile) Type() Enablement {
return t.et
}
func (t *Tmpfile) apply(_ *I) error {
switch t.method {
case tmpfileCopy:
fmsg.VPrintln("publishing tmpfile", t)
return fmsg.WrapErrorSuffix(copyFile(t.dst, t.src),
fmt.Sprintf("cannot copy tmpfile %q:", t.dst))
case tmpfileLink:
fmsg.VPrintln("linking tmpfile", t)
return fmsg.WrapErrorSuffix(os.Link(t.src, t.dst),
fmt.Sprintf("cannot link tmpfile %q:", t.dst))
case tmpfileWrite:
fmsg.VPrintln("writing", t)
return fmsg.WrapErrorSuffix(os.WriteFile(t.dst, []byte(t.src), 0600),
fmt.Sprintf("cannot write tmpfile %q:", t.dst))
default:
panic("invalid tmpfile method " + strconv.Itoa(int(t.method)))
}
}
func (t *Tmpfile) revert(_ *I, ec *Criteria) error {
if ec.hasType(t) {
fmsg.VPrintf("removing tmpfile %q", t.dst)
return fmsg.WrapErrorSuffix(os.Remove(t.dst),
fmt.Sprintf("cannot remove tmpfile %q:", t.dst))
} else {
fmsg.VPrintf("skipping tmpfile %q", t.dst)
return nil
}
}
func (t *Tmpfile) Is(o Op) bool {
t0, ok := o.(*Tmpfile)
return ok && t0 != nil && *t == *t0
}
func (t *Tmpfile) Path() string {
if t.method == tmpfileWrite {
return fmt.Sprintf("(%d bytes of data)", len(t.src))
}
return t.src
}
func (t *Tmpfile) String() string {
switch t.method {
case tmpfileCopy:
return fmt.Sprintf("%q from %q", t.dst, t.src)
case tmpfileLink:
return fmt.Sprintf("%q from %q", t.dst, t.src)
case tmpfileWrite:
return fmt.Sprintf("%d bytes of data to %q", len(t.src), t.dst)
default:
panic("invalid tmpfile method " + strconv.Itoa(int(t.method)))
}
}
func copyFile(dst, src string) error {
dstD, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
srcD, err := os.Open(src)
if err != nil {
return errors.Join(err, dstD.Close())
}
_, err = io.Copy(dstD, srcD)
return errors.Join(err, dstD.Close(), srcD.Close())
}

View File

@ -1,167 +0,0 @@
package system
import (
"strconv"
"testing"
"git.ophivana.moe/security/fortify/acl"
)
func TestCopyFile(t *testing.T) {
testCases := []struct {
dst, src string
}{
{"/tmp/fortify.1971/f587afe9fce3c8e1ad5b64deb6c41ad5/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"},
{"/tmp/fortify.1971/62154f708b5184ab01f9dcc2bbe7a33b/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"},
}
for _, tc := range testCases {
t.Run("copy file "+tc.dst+" from "+tc.src, func(t *testing.T) {
sys := New(150)
sys.CopyFile(tc.dst, tc.src)
(&tcOp{Process, tc.src}).test(t, sys.ops, []Op{
&Tmpfile{Process, tmpfileCopy, tc.dst, tc.src},
&ACL{Process, tc.dst, []acl.Perm{acl.Read}},
}, "CopyFile")
})
}
}
func TestCopyFileType(t *testing.T) {
testCases := []struct {
tcOp
dst string
}{
{tcOp{User, "/tmp/fortify.1971/f587afe9fce3c8e1ad5b64deb6c41ad5/pulse-cookie"}, "/home/ophestra/xdg/config/pulse/cookie"},
{tcOp{Process, "/tmp/fortify.1971/62154f708b5184ab01f9dcc2bbe7a33b/pulse-cookie"}, "/home/ophestra/xdg/config/pulse/cookie"},
}
for _, tc := range testCases {
t.Run("copy file "+tc.dst+" from "+tc.path+" with type "+TypeString(tc.et), func(t *testing.T) {
sys := New(150)
sys.CopyFileType(tc.et, tc.dst, tc.path)
tc.test(t, sys.ops, []Op{
&Tmpfile{tc.et, tmpfileCopy, tc.dst, tc.path},
&ACL{tc.et, tc.dst, []acl.Perm{acl.Read}},
}, "CopyFileType")
})
}
}
func TestLink(t *testing.T) {
testCases := []struct {
dst, src string
}{
{"/tmp/fortify.1971/f587afe9fce3c8e1ad5b64deb6c41ad5/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"},
{"/tmp/fortify.1971/62154f708b5184ab01f9dcc2bbe7a33b/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie"},
}
for _, tc := range testCases {
t.Run("link file "+tc.dst+" from "+tc.src, func(t *testing.T) {
sys := New(150)
sys.Link(tc.src, tc.dst)
(&tcOp{Process, tc.src}).test(t, sys.ops, []Op{
&Tmpfile{Process, tmpfileLink, tc.dst, tc.src},
}, "Link")
})
}
}
func TestLinkFileType(t *testing.T) {
testCases := []struct {
tcOp
dst string
}{
{tcOp{User, "/tmp/fortify.1971/f587afe9fce3c8e1ad5b64deb6c41ad5/pulse-cookie"}, "/home/ophestra/xdg/config/pulse/cookie"},
{tcOp{Process, "/tmp/fortify.1971/62154f708b5184ab01f9dcc2bbe7a33b/pulse-cookie"}, "/home/ophestra/xdg/config/pulse/cookie"},
}
for _, tc := range testCases {
t.Run("link file "+tc.dst+" from "+tc.path+" with type "+TypeString(tc.et), func(t *testing.T) {
sys := New(150)
sys.LinkFileType(tc.et, tc.path, tc.dst)
tc.test(t, sys.ops, []Op{
&Tmpfile{tc.et, tmpfileLink, tc.dst, tc.path},
}, "LinkFileType")
})
}
}
func TestWrite(t *testing.T) {
testCases := []struct {
dst, src string
}{
{"/etc/passwd", "chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n"},
{"/etc/group", "fortify:x:65534:\n"},
}
for _, tc := range testCases {
t.Run("write "+strconv.Itoa(len(tc.src))+" bytes to "+tc.dst, func(t *testing.T) {
sys := New(150)
sys.Write(tc.dst, tc.src)
(&tcOp{Process, "(" + strconv.Itoa(len(tc.src)) + " bytes of data)"}).test(t, sys.ops, []Op{
&Tmpfile{Process, tmpfileWrite, tc.dst, tc.src},
&ACL{Process, tc.dst, []acl.Perm{acl.Read}},
}, "Write")
})
}
}
func TestWriteType(t *testing.T) {
testCases := []struct {
et Enablement
dst, src string
}{
{Process, "/etc/passwd", "chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n"},
{Process, "/etc/group", "fortify:x:65534:\n"},
{User, "/etc/passwd", "chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n"},
{User, "/etc/group", "fortify:x:65534:\n"},
}
for _, tc := range testCases {
t.Run("write "+strconv.Itoa(len(tc.src))+" bytes to "+tc.dst+" with type "+TypeString(tc.et), func(t *testing.T) {
sys := New(150)
sys.WriteType(tc.et, tc.dst, tc.src)
(&tcOp{tc.et, "(" + strconv.Itoa(len(tc.src)) + " bytes of data)"}).test(t, sys.ops, []Op{
&Tmpfile{tc.et, tmpfileWrite, tc.dst, tc.src},
&ACL{tc.et, tc.dst, []acl.Perm{acl.Read}},
}, "WriteType")
})
}
}
func TestTmpfile_String(t *testing.T) {
t.Run("invalid method panic", func(t *testing.T) {
defer func() {
wantPanic := "invalid tmpfile method 255"
if r := recover(); r != wantPanic {
t.Errorf("String() panic = %v, want %v",
r, wantPanic)
}
}()
_ = (&Tmpfile{method: 255}).String()
})
testCases := []struct {
method uint8
dst, src string
want string
}{
{tmpfileCopy, "/tmp/fortify.1971/4b6bdc9182fb2f1d3a965c5fa8b9b66e/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie",
`"/tmp/fortify.1971/4b6bdc9182fb2f1d3a965c5fa8b9b66e/pulse-cookie" from "/home/ophestra/xdg/config/pulse/cookie"`},
{tmpfileLink, "/run/user/1971/fortify/4b6bdc9182fb2f1d3a965c5fa8b9b66e/wayland", "/run/user/1971/wayland-0",
`"/run/user/1971/fortify/4b6bdc9182fb2f1d3a965c5fa8b9b66e/wayland" from "/run/user/1971/wayland-0"`},
{tmpfileLink, "/run/user/1971/fortify/4b6bdc9182fb2f1d3a965c5fa8b9b66e/pulse", "/run/user/1971/pulse/native",
`"/run/user/1971/fortify/4b6bdc9182fb2f1d3a965c5fa8b9b66e/pulse" from "/run/user/1971/pulse/native"`},
{tmpfileWrite, "/tmp/fortify.1971/4b6bdc9182fb2f1d3a965c5fa8b9b66e/passwd", "chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n",
`75 bytes of data to "/tmp/fortify.1971/4b6bdc9182fb2f1d3a965c5fa8b9b66e/passwd"`},
{tmpfileWrite, "/tmp/fortify.1971/4b6bdc9182fb2f1d3a965c5fa8b9b66e/group", "fortify:x:65534:\n",
`17 bytes of data to "/tmp/fortify.1971/4b6bdc9182fb2f1d3a965c5fa8b9b66e/group"`},
}
for _, tc := range testCases {
t.Run(tc.want, func(t *testing.T) {
if got := (&Tmpfile{
method: tc.method,
dst: tc.dst,
src: tc.src,
}).String(); got != tc.want {
t.Errorf("String() = %v, want %v", got, tc.want)
}
})
}
}

View File

@ -1,54 +0,0 @@
package system
import (
"fmt"
"git.ophivana.moe/security/fortify/internal/fmsg"
"git.ophivana.moe/security/fortify/xcb"
)
// ChangeHosts appends an X11 ChangeHosts command Op.
func (sys *I) ChangeHosts(username string) *I {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, XHost(username))
return sys
}
type XHost string
func (x XHost) Type() Enablement {
return EX11
}
func (x XHost) apply(_ *I) error {
fmsg.VPrintf("inserting entry %s to X11", x)
return fmsg.WrapErrorSuffix(xcb.ChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)),
fmt.Sprintf("cannot insert entry %s to X11:", x))
}
func (x XHost) revert(_ *I, ec *Criteria) error {
if ec.hasType(x) {
fmsg.VPrintf("deleting entry %s from X11", x)
return fmsg.WrapErrorSuffix(xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)),
fmt.Sprintf("cannot delete entry %s from X11:", x))
} else {
fmsg.VPrintf("skipping entry %s in X11", x)
return nil
}
}
func (x XHost) Is(o Op) bool {
x0, ok := o.(XHost)
return ok && x == x0
}
func (x XHost) Path() string {
return string(x)
}
func (x XHost) String() string {
return string("SI:localuser:" + x)
}

View File

@ -1,34 +0,0 @@
package system
import (
"testing"
)
func TestChangeHosts(t *testing.T) {
testCases := []string{"chronos", "keyring", "cat", "kbd", "yonah"}
for _, tc := range testCases {
t.Run("append ChangeHosts operation for "+tc, func(t *testing.T) {
sys := New(150)
sys.ChangeHosts(tc)
(&tcOp{EX11, tc}).test(t, sys.ops, []Op{
XHost(tc),
}, "ChangeHosts")
})
}
}
func TestXHost_String(t *testing.T) {
testCases := []struct {
username string
want string
}{
{"chronos", "SI:localuser:chronos"},
}
for _, tc := range testCases {
t.Run(tc.want, func(t *testing.T) {
if got := XHost(tc.username).String(); got != tc.want {
t.Errorf("String() = %v, want %v", got, tc.want)
}
})
}
}

17
internal/verbose/print.go Normal file
View File

@ -0,0 +1,17 @@
package verbose
import "fmt"
const prefix = "fortify:"
func Println(a ...any) {
if verbose.Load() {
fmt.Println(append([]any{prefix}, a...)...)
}
}
func Printf(format string, a ...any) {
if verbose.Load() {
fmt.Printf(prefix+" "+format, a...)
}
}

View File

@ -0,0 +1,67 @@
package verbose_test
import (
"os"
"os/exec"
"strconv"
"strings"
"testing"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
const (
testVerbose = "GO_TEST_VERBOSE"
wantStdout = "fortify: println\nfortify: printf"
)
func TestPrinter(t *testing.T) {
switch os.Getenv(testVerbose) {
case "0":
verbose.Set(false)
case "1":
verbose.Set(true)
default:
return
}
verbose.Println("println")
verbose.Printf("%s", "printf")
}
func TestPrintf_Println(t *testing.T) {
testPrintfPrintln(t, false)
testPrintfPrintln(t, true)
// make -cover happy
stdout := os.Stdout
t.Cleanup(func() {
os.Stdout = stdout
})
os.Stdout = nil
verbose.Set(true)
verbose.Printf("")
verbose.Println()
}
func testPrintfPrintln(t *testing.T, v bool) {
t.Run("start verbose printer with verbose "+strconv.FormatBool(v), func(t *testing.T) {
stdout, stderr := new(strings.Builder), new(strings.Builder)
stdout.Grow(len(wantStdout))
cmd := exec.Command(os.Args[0], "-test.run=TestPrinter")
cmd.Stdout, cmd.Stderr = stdout, stderr
if v {
cmd.Env = append(cmd.Env, testVerbose+"=1")
} else {
cmd.Env = append(cmd.Env, testVerbose+"=0")
}
if err := cmd.Run(); err != nil {
panic("cannot run printer process: " + err.Error() + " stderr: " + stderr.String())
}
if got := stdout.String(); strings.Contains(got, wantStdout) != v {
t.Errorf("Print: got %v; want %t",
got, v)
}
})
}

13
internal/verbose/state.go Normal file
View File

@ -0,0 +1,13 @@
package verbose
import "sync/atomic"
var verbose = new(atomic.Bool)
func Get() bool {
return verbose.Load()
}
func Set(v bool) {
verbose.Store(v)
}

View File

@ -0,0 +1,20 @@
package verbose_test
import (
"testing"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
func TestGetSet(t *testing.T) {
verbose.Set(false)
if verbose.Get() {
t.Errorf("Get() = true, want false")
}
verbose.Set(true)
if !verbose.Get() {
t.Errorf("Get() = false, want true")
}
}

View File

@ -1,27 +1,11 @@
package ldd package ldd
import ( import "fmt"
"errors"
"fmt"
)
var ( type EntryUnexpectedSegmentsError struct {
ErrUnexpectedSeparator = errors.New("unexpected separator") Entry string
ErrPathNotAbsolute = errors.New("path not absolute")
ErrBadLocationFormat = errors.New("bad location format")
ErrUnexpectedNewline = errors.New("unexpected newline")
)
type EntryUnexpectedSegmentsError string
func (e EntryUnexpectedSegmentsError) Is(err error) bool {
var eq EntryUnexpectedSegmentsError
if !errors.As(err, &eq) {
return false
}
return e == eq
} }
func (e EntryUnexpectedSegmentsError) Error() string { func (e *EntryUnexpectedSegmentsError) Error() string {
return fmt.Sprintf("unexpected segments in entry %q", string(e)) return fmt.Sprintf("unexpected segments in entry %q", e.Entry)
} }

View File

@ -1,41 +0,0 @@
package ldd
import (
"fmt"
"os"
"os/exec"
"strings"
"git.ophivana.moe/security/fortify/helper"
"git.ophivana.moe/security/fortify/helper/bwrap"
)
func Exec(p string) ([]*Entry, error) {
var (
h helper.Helper
cmd *exec.Cmd
)
if b, err := helper.NewBwrap((&bwrap.Config{
Hostname: "fortify-ldd",
Chdir: "/",
NewSession: true,
DieWithParent: true,
}).Bind("/", "/").DevTmpfs("/dev"),
nil, "ldd", func(_, _ int) []string { return []string{p} }); err != nil {
return nil, err
} else {
cmd = b.Unwrap()
h = b
}
cmd.Stdout, cmd.Stderr = new(strings.Builder), os.Stderr
if err := h.Start(); err != nil {
return nil, err
}
if err := h.Wait(); err != nil {
return nil, err
}
return Parse(cmd.Stdout.(fmt.Stringer))
}

View File

@ -1,31 +1,49 @@
package ldd package ldd
import ( import (
"errors"
"fmt" "fmt"
"math" "math"
"os"
"os/exec"
"path" "path"
"strconv" "strconv"
"strings" "strings"
) )
var (
ErrUnexpectedSeparator = errors.New("unexpected separator")
ErrPathNotAbsolute = errors.New("path not absolute")
ErrBadLocationFormat = errors.New("bad location format")
)
type Entry struct { type Entry struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
Location uint64 `json:"location"` Location uint64 `json:"location"`
} }
func Parse(stdout fmt.Stringer) ([]*Entry, error) { func Exec(p string) ([]*Entry, error) {
payload := strings.Split(strings.TrimSpace(stdout.String()), "\n") t := exec.Command("ldd", p)
t.Stdout = new(strings.Builder)
t.Stderr = os.Stderr
if err := t.Run(); err != nil {
return nil, err
}
out := t.Stdout.(fmt.Stringer).String()
payload := strings.Split(out, "\n")
result := make([]*Entry, len(payload)) result := make([]*Entry, len(payload))
for i, ent := range payload { for i, ent := range payload {
if len(ent) == 0 { if len(ent) == 0 {
return nil, ErrUnexpectedNewline continue
} }
segment := strings.SplitN(ent, " ", 5) segment := strings.SplitN(ent, " ", 5)
// location index
var iL int var iL int
switch len(segment) { switch len(segment) {
@ -45,7 +63,7 @@ func Parse(stdout fmt.Stringer) ([]*Entry, error) {
Path: segment[2], Path: segment[2],
} }
default: default:
return nil, EntryUnexpectedSegmentsError(ent) return nil, &EntryUnexpectedSegmentsError{ent}
} }
if loc, err := parseLocation(segment[iL]); err != nil { if loc, err := parseLocation(segment[iL]); err != nil {

View File

@ -1,95 +0,0 @@
package ldd_test
import (
"errors"
"reflect"
"strings"
"testing"
"git.ophivana.moe/security/fortify/ldd"
)
func TestParseError(t *testing.T) {
testCases := []struct {
name, out string
wantErr error
}{
{"unexpected newline", `
/lib/ld-musl-x86_64.so.1 (0x7ff71c0a4000)
libzstd.so.1 => /usr/lib/libzstd.so.1 (0x7ff71bfd2000)
`, ldd.ErrUnexpectedNewline},
{"unexpected separator", `
libzstd.so.1 = /usr/lib/libzstd.so.1 (0x7ff71bfd2000)
`, ldd.ErrUnexpectedSeparator},
{"path not absolute", `
libzstd.so.1 => usr/lib/libzstd.so.1 (0x7ff71bfd2000)
`, ldd.ErrPathNotAbsolute},
{"unexpected segments", `
meow libzstd.so.1 => /usr/lib/libzstd.so.1 (0x7ff71bfd2000)
`, ldd.EntryUnexpectedSegmentsError("meow libzstd.so.1 => /usr/lib/libzstd.so.1 (0x7ff71bfd2000)")},
{"bad location format", `
libzstd.so.1 => /usr/lib/libzstd.so.1 7ff71bfd2000
`, ldd.ErrBadLocationFormat},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
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)
}
})
}
}
func TestParse(t *testing.T) {
testCases := []struct {
file, out string
want []*ldd.Entry
}{
{"musl /bin/kmod", `
/lib/ld-musl-x86_64.so.1 (0x7ff71c0a4000)
libzstd.so.1 => /usr/lib/libzstd.so.1 (0x7ff71bfd2000)
liblzma.so.5 => /usr/lib/liblzma.so.5 (0x7ff71bf9a000)
libz.so.1 => /lib/libz.so.1 (0x7ff71bf80000)
libcrypto.so.3 => /lib/libcrypto.so.3 (0x7ff71ba00000)
libc.musl-x86_64.so.1 => /lib/ld-musl-x86_64.so.1 (0x7ff71c0a4000)`,
[]*ldd.Entry{
{"/lib/ld-musl-x86_64.so.1", "", 0x7ff71c0a4000},
{"libzstd.so.1", "/usr/lib/libzstd.so.1", 0x7ff71bfd2000},
{"liblzma.so.5", "/usr/lib/liblzma.so.5", 0x7ff71bf9a000},
{"libz.so.1", "/lib/libz.so.1", 0x7ff71bf80000},
{"libcrypto.so.3", "/lib/libcrypto.so.3", 0x7ff71ba00000},
{"libc.musl-x86_64.so.1", "/lib/ld-musl-x86_64.so.1", 0x7ff71c0a4000},
}},
{"glibc /nix/store/rc3n2r3nffpib2gqpxlkjx36frw6n34z-kmod-31/bin/kmod", `
linux-vdso.so.1 (0x00007ffed65be000)
libzstd.so.1 => /nix/store/80pxmvb9q43kh9rkjagc4h41vf6dh1y6-zstd-1.5.6/lib/libzstd.so.1 (0x00007f3199cd1000)
liblzma.so.5 => /nix/store/g78jna1i5qhh8gqs4mr64648f0szqgw4-xz-5.4.7/lib/liblzma.so.5 (0x00007f3199ca2000)
libc.so.6 => /nix/store/c10zhkbp6jmyh0xc5kd123ga8yy2p4hk-glibc-2.39-52/lib/libc.so.6 (0x00007f3199ab5000)
libpthread.so.0 => /nix/store/c10zhkbp6jmyh0xc5kd123ga8yy2p4hk-glibc-2.39-52/lib/libpthread.so.0 (0x00007f3199ab0000)
/nix/store/c10zhkbp6jmyh0xc5kd123ga8yy2p4hk-glibc-2.39-52/lib/ld-linux-x86-64.so.2 => /nix/store/c10zhkbp6jmyh0xc5kd123ga8yy2p4hk-glibc-2.39-52/lib64/ld-linux-x86-64.so.2 (0x00007f3199da5000)`,
[]*ldd.Entry{
{"linux-vdso.so.1", "", 0x00007ffed65be000},
{"libzstd.so.1", "/nix/store/80pxmvb9q43kh9rkjagc4h41vf6dh1y6-zstd-1.5.6/lib/libzstd.so.1", 0x00007f3199cd1000},
{"liblzma.so.5", "/nix/store/g78jna1i5qhh8gqs4mr64648f0szqgw4-xz-5.4.7/lib/liblzma.so.5", 0x00007f3199ca2000},
{"libc.so.6", "/nix/store/c10zhkbp6jmyh0xc5kd123ga8yy2p4hk-glibc-2.39-52/lib/libc.so.6", 0x00007f3199ab5000},
{"libpthread.so.0", "/nix/store/c10zhkbp6jmyh0xc5kd123ga8yy2p4hk-glibc-2.39-52/lib/libpthread.so.0", 0x00007f3199ab0000},
{"/nix/store/c10zhkbp6jmyh0xc5kd123ga8yy2p4hk-glibc-2.39-52/lib/ld-linux-x86-64.so.2", "/nix/store/c10zhkbp6jmyh0xc5kd123ga8yy2p4hk-glibc-2.39-52/lib64/ld-linux-x86-64.so.2", 0x00007f3199da5000},
}},
}
for _, tc := range testCases {
t.Run(tc.file, func(t *testing.T) {
stdout := new(strings.Builder)
stdout.WriteString(tc.out)
if got, err := ldd.Parse(stdout); err != nil {
t.Errorf("Parse() error = %v", err)
} else if !reflect.DeepEqual(got, tc.want) {
t.Errorf("Parse() got = %#v, want %#v", got, tc.want)
}
})
}
}

View File

@ -4,6 +4,7 @@ import (
_ "embed" _ "embed"
"flag" "flag"
"fmt" "fmt"
"os"
) )
var ( var (

200
main.go
View File

@ -1,78 +1,186 @@
package main package main
import ( import (
"encoding/json"
"errors"
"flag" "flag"
"syscall" "fmt"
"os"
"git.ophivana.moe/security/fortify/internal" "git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/security/fortify/internal/app" "git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.ophivana.moe/cat/fortify/internal/app"
init0 "git.ophivana.moe/security/fortify/internal/init" "git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/security/fortify/internal/shim" "git.ophivana.moe/cat/fortify/internal/verbose"
) )
var ( var (
flagVerbose bool Version = "impure"
) )
func init() { func tryVersion() {
flag.BoolVar(&flagVerbose, "v", false, "Verbose output") if printVersion {
fmt.Println(Version)
os.Exit(0)
}
} }
var os = new(internal.Std)
func main() { func main() {
// linux/sched/coredump.h
if _, _, errno := syscall.RawSyscall(syscall.SYS_PRCTL, syscall.PR_SET_DUMPABLE, 0, 0); errno != 0 {
fmsg.Printf("fortify: cannot set SUID_DUMP_DISABLE: %s", errno.Error())
}
flag.Parse() flag.Parse()
fmsg.SetVerbose(flagVerbose) verbose.Set(flagVerbose)
if os.SdBooted() { if internal.SdBootedV {
fmsg.VPrintln("system booted with systemd as init system") verbose.Println("system booted with systemd as init system")
} }
// shim/init early exit // launcher payload early exit
init0.Try() if printVersion && printLicense {
shim.Try() app.TryShim()
// root check
if os.Geteuid() == 0 {
fmsg.Fatal("this program must not run as root")
panic("unreachable")
} }
// version/license/template command early exit // version/license command early exit
tryVersion() tryVersion()
tryLicense() tryLicense()
tryTemplate()
// state query command early exit // state query command early exit
tryState() tryState()
// invoke app // prepare config
a, err := app.New(os) var config *app.Config
if err != nil {
fmsg.Fatalf("cannot create app: %s\n", err) if confPath == "nil" {
} else if err = a.Seal(loadConfig()); err != nil { // config from flags
logBaseError(err, "cannot seal app:") config = configFromFlags()
fmsg.Exit(1) } else {
} else if err = a.Start(); err != nil { // config from file
logBaseError(err, "cannot start app:") if f, err := os.Open(confPath); err != nil {
fatalf("cannot access config file '%s': %s\n", confPath, err)
} else {
if err = json.NewDecoder(f).Decode(&config); err != nil {
fatalf("cannot parse config file '%s': %s\n", confPath, err)
}
}
} }
var r int // invoke app
// wait must be called regardless of result of start r := 1
if r, err = a.Wait(); err != nil { a := app.New()
if r < 1 { if err := a.Seal(config); err != nil {
logBaseError(err, "fortify: cannot seal app:")
} else if err = a.Start(); err != nil {
logBaseError(err, "fortify: cannot start app:")
} else if r, err = a.Wait(); err != nil {
r = 1 r = 1
var e *app.BaseError
if !app.AsBaseError(err, &e) {
fmt.Println("fortify: 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
fmt.Print("fortify: " + 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
fmt.Print("fortify: " + e.Message())
} else {
errs := ej.Unwrap()
// every error here is wrapped in *app.BaseError
for _, ei := range errs {
var eb *app.BaseError
if !errors.As(ei, &eb) {
// unreachable
fmt.Println("fortify: invalid error type returned by revert:", ei)
} else {
// print inner *app.BaseError message
fmt.Print("fortify: " + eb.Message())
} }
logWaitError(err)
} }
if err = a.WaitErr(); err != nil {
fmsg.Println("inner wait failed:", err)
} }
fmsg.Exit(r) }
}
}
if err := a.WaitErr(); err != nil {
fmt.Println("fortify: inner wait failed:", err)
}
os.Exit(r)
}
func logBaseError(err error, message string) {
var e *app.BaseError
if app.AsBaseError(err, &e) {
fmt.Print("fortify: " + e.Message())
} else {
fmt.Println(message, err)
}
}
func configFromFlags() (config *app.Config) {
// initialise config from flags
config = &app.Config{
ID: dbusID,
User: userName,
Command: flag.Args(),
Method: launchMethodText,
}
// enablements from flags
if mustWayland {
config.Confinement.Enablements.Set(state.EnableWayland)
}
if mustX {
config.Confinement.Enablements.Set(state.EnableX)
}
if mustDBus {
config.Confinement.Enablements.Set(state.EnableDBus)
}
if mustPulse {
config.Confinement.Enablements.Set(state.EnablePulse)
}
// parse D-Bus config file from flags if applicable
if mustDBus {
if dbusConfigSession == "builtin" {
config.Confinement.SessionBus = dbus.NewConfig(dbusID, true, mpris)
} else {
if f, err := os.Open(dbusConfigSession); err != nil {
fatalf("cannot access session bus proxy config file '%s': %s\n", dbusConfigSession, err)
} else {
if err = json.NewDecoder(f).Decode(&config.Confinement.SessionBus); err != nil {
fatalf("cannot parse session bus proxy config file '%s': %s\n", dbusConfigSession, err)
}
}
}
// system bus proxy is optional
if dbusConfigSystem != "nil" {
if f, err := os.Open(dbusConfigSystem); err != nil {
fatalf("cannot access system bus proxy config file '%s': %s\n", dbusConfigSystem, err)
} else {
if err = json.NewDecoder(f).Decode(&config.Confinement.SystemBus); err != nil {
fatalf("cannot parse system bus proxy config file '%s': %s\n", dbusConfigSystem, err)
}
}
}
if dbusVerbose {
config.Confinement.SessionBus.Log = true
config.Confinement.SystemBus.Log = true
}
}
return
}
func fatalf(format string, a ...any) {
fmt.Printf("fortify: "+format, a...)
os.Exit(1)
} }

View File

@ -148,6 +148,7 @@ in
type = enum [ type = enum [
"simple" "simple"
"sudo" "sudo"
"bubblewrap"
"systemd" "systemd"
]; ];
default = "systemd"; default = "systemd";
@ -199,6 +200,14 @@ in
description = "Privileged user account."; description = "Privileged user account.";
}; };
shell = mkOption {
type = types.str;
description = ''
Shell set up to source home-manager for the privileged user.
Required for setting up the environment of sandboxed programs.
'';
};
stateDir = mkOption { stateDir = mkOption {
type = types.str; type = types.str;
description = '' description = ''
@ -234,14 +243,14 @@ in
else else
null; null;
capArgs = capArgs =
(if wayland then " --wayland" else "") (if wayland then " -wayland" else "")
+ (if x11 then " -X" else "") + (if x11 then " -X" else "")
+ (if dbus then " --dbus" else "") + (if dbus then " -dbus" else "")
+ (if pulse then " --pulse" else "") + (if pulse then " -pulse" else "")
+ (if launcher.dbus.mpris then " --mpris" else "") + (if launcher.dbus.mpris then " -mpris" else "")
+ (if launcher.dbus.id != null then " --dbus-id ${launcher.dbus.id}" else "") + (if launcher.dbus.id != null then " -dbus-id ${launcher.dbus.id}" else "")
+ (if dbusConfig != null then " --dbus-config ${dbusConfig}" else "") + (if dbusConfig != null then " -dbus-config ${dbusConfig}" else "")
+ (if dbusSystem != null then " --dbus-system ${dbusSystem}" else ""); + (if dbusSystem != null then " -dbus-system ${dbusSystem}" else "");
in in
pkgs.writeShellScriptBin name ( pkgs.writeShellScriptBin name (
if launcher.method == "simple" then if launcher.method == "simple" then
@ -250,7 +259,7 @@ in
'' ''
else else
'' ''
exec fortify${capArgs} --method ${launcher.method} -u ${user} $SHELL -c "exec ${command} $@" exec fortify${capArgs} -method ${launcher.method} -u ${user} ${cfg.shell} -c "exec ${command} $@"
'' ''
) )
) launchers; ) launchers;

View File

@ -10,7 +10,7 @@
buildGoModule rec { buildGoModule rec {
pname = "fortify"; pname = "fortify";
version = "0.0.10"; version = "0.0.1";
src = ./.; src = ./.;
vendorHash = null; vendorHash = null;
@ -20,8 +20,6 @@ buildGoModule rec {
"-w" "-w"
"-X" "-X"
"main.Version=v${version}" "main.Version=v${version}"
"-X"
"main.FortifyPath=${placeholder "out"}/bin/.fortify-wrapped"
]; ];
buildInputs = [ buildInputs = [
@ -38,7 +36,5 @@ buildGoModule rec {
xdg-dbus-proxy xdg-dbus-proxy
] ]
} }
mv $out/bin/fsu $out/bin/.fsu
''; '';
} }

View File

@ -3,10 +3,10 @@ package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"os"
"text/tabwriter" "text/tabwriter"
"git.ophivana.moe/security/fortify/internal/fmsg" "git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/security/fortify/internal/state"
) )
var ( var (
@ -21,15 +21,15 @@ func init() {
func tryState() { func tryState() {
if stateActionEarly { if stateActionEarly {
var w *tabwriter.Writer var w *tabwriter.Writer
state.MustPrintLauncherStateSimpleGlobal(&w, os.Paths().RunDirPath) state.MustPrintLauncherStateSimpleGlobal(&w)
if w != nil { if w != nil {
if err := w.Flush(); err != nil { if err := w.Flush(); err != nil {
fmsg.Println("cannot format output:", err) fmt.Println("warn: error formatting output:", err)
} }
} else { } else {
fmt.Println("No information available") fmt.Println("No information available")
} }
fmsg.Exit(0) os.Exit(0)
} }
} }

View File

@ -1,23 +0,0 @@
package main
import (
"flag"
"fmt"
)
var (
Version = "impure"
printVersion bool
)
func init() {
flag.BoolVar(&printVersion, "V", false, "Print version")
}
func tryVersion() {
if printVersion {
fmt.Println(Version)
os.Exit(0)
}
}

View File

@ -37,7 +37,8 @@ var (
) )
func ChangeHosts(mode, family C.uint8_t, address string) error { func ChangeHosts(mode, family C.uint8_t, address string) error {
c := C.xcb_connect(nil, nil) var c *C.xcb_connection_t
c = C.xcb_connect(nil, nil)
defer C.xcb_disconnect(c) defer C.xcb_disconnect(c)
if err := xcbHandleConnectionError(c); err != nil { if err := xcbHandleConnectionError(c); err != nil {