Compare commits

..

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

137 changed files with 3408 additions and 8490 deletions

View File

@ -1,46 +0,0 @@
name: Nix
on:
- push
- pull_request
jobs:
tests:
name: NixOS tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Nix
uses: cachix/install-nix-action@08dcb3a5e62fa31e2da3d490afc4176ef55ecd72 # v30
with:
# explicitly enable sandbox
install_options: --daemon
extra_nix_config: |
sandbox = true
system-features = nixos-test benchmark big-parallel kvm
enable_kvm: true
- name: Ensure environment
run: >-
apt-get update && apt-get install -y sqlite3
if: ${{ runner.os == 'Linux' }}
- name: Restore Nix store
uses: nix-community/cache-nix-action@v5
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
- name: Run tests
run: |
nix --print-build-logs --experimental-features 'nix-command flakes' flake check --all-systems
nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.nixos-tests
- name: Upload test output
uses: actions/upload-artifact@v3
with:
name: "result"
path: result/*
retention-days: 1

View File

@ -1,4 +1,4 @@
name: Create distribution name: release
on: on:
push: push:
@ -7,47 +7,32 @@ on:
jobs: jobs:
release: release:
name: Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: node:16-bookworm-slim
steps: steps:
- name: Get dependencies
run: >-
echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list.d/backports.list &&
apt-get update &&
apt-get install -y
acl
git
gcc
pkg-config
libwayland-dev
wayland-protocols/bookworm-backports
libxcb1-dev
libacl1-dev
if: ${{ runner.os == 'Linux' }}
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up go
- name: Setup 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: Go generate
run: >- run: >-
go generate ./... sudo apt-get update &&
sudo apt-get install -y
- name: Build for release gcc
run: FORTIFY_VERSION='${{ github.ref_name }}' ./dist/release.sh pkg-config
libacl1-dev
if: ${{ runner.os == 'Linux' }}
- 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"
- 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
with: with:
files: |- files: |-
dist/fortify-** bin/**
api_key: '${{secrets.RELEASE_TOKEN}}' api_key: '${{secrets.RELEASE_TOKEN}}'

View File

@ -1,62 +0,0 @@
name: Tests
on:
- push
- pull_request
jobs:
test:
name: Go tests
runs-on: ubuntu-latest
container:
image: node:16-bookworm-slim
steps:
- name: Enable backports
run: >-
echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list.d/backports.list
if: ${{ runner.os == 'Linux' }}
- name: Ensure environment
run: >-
apt-get update && apt-get install -y curl wget sudo libxml2
if: ${{ runner.os == 'Linux' }}
- name: Get dependencies
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: acl git gcc pkg-config libwayland-dev wayland-protocols/bookworm-backports libxcb1-dev libacl1-dev
version: 1.0
#execute_install_scripts: true
if: ${{ runner.os == 'Linux' }}
- 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: Go generate
run: >-
go generate ./...
- name: Run tests
run: >-
go test ./...
- name: Build for test
id: build-test
run: >-
FORTIFY_VERSION="$(git rev-parse --short HEAD)"
bash -c './dist/release.sh &&
echo "rev=$FORTIFY_VERSION" >> $GITHUB_OUTPUT'
- name: Upload test build
uses: actions/upload-artifact@v3
with:
name: "fortify-${{ steps.build-test.outputs.rev }}"
path: dist/fortify-*
retention-days: 1

6
.gitignore vendored
View File

@ -23,9 +23,3 @@ go.work.sum
.env .env
.idea .idea
.vscode .vscode
# go generate
security-context-v1-protocol.*
# release
/dist/fortify-*

165
README.md
View File

@ -1,10 +1,9 @@
Fortify Fortify
======= =======
[![Go Reference](https://pkg.go.dev/badge/git.gensokyo.uk/security/fortify.svg)](https://pkg.go.dev/git.gensokyo.uk/security/fortify) [![Go Reference](https://pkg.go.dev/badge/git.ophivana.moe/cat/fortify.svg)](https://pkg.go.dev/git.ophivana.moe/cat/fortify)
[![Go Report Card](https://goreportcard.com/badge/git.gensokyo.uk/security/fortify)](https://goreportcard.com/report/git.gensokyo.uk/security/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?
@ -13,19 +12,26 @@ 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:
- A set of users, each for a group of applications that should be allowed access to each other
- A tool to switch users, currently sudo and machinectl are supported.
- If you are running NixOS, the module in this repository can take care of launchers and desktop files in the privileged
user's environment, as well as packages and extra home-manager configuration for target users.
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.gensokyo.uk/security/fortify -- help nix run git+https://git.ophivana.moe/cat/fortify -- -h
``` ```
## Module usage ## Module usage
The NixOS module currently requires home-manager to function correctly. The NixOS module currently requires home-manager and impermanence to function correctly.
Full module documentation can be found [here](options.md).
To use the module, import it into your configuration with To use the module, import it into your configuration with
@ -35,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.gensokyo.uk/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";
@ -62,21 +68,21 @@ This adds the `environment.fortify` option:
{ {
environment.fortify = { environment.fortify = {
enable = true; enable = true;
stateDir = "/var/lib/persist/module/fortify"; user = "nixos";
users = { stateDir = "/var/lib/persist/module";
alice = 0; target = {
nixos = 10; chronos = {
launchers = {
weechat.method = "sudo";
claws-mail.capability.pulse = false;
discord = {
command = "vesktop --ozone-platform-hint=wayland";
share = pkgs.vesktop;
}; };
apps = [ chromium.dbus = {
{ configSystem = {
name = "chromium";
id = "org.chromium.Chromium";
packages = [ pkgs.chromium ];
userns = true;
mapRealUid = true;
dbus = {
system = {
filter = true; filter = true;
talk = [ talk = [
"org.bluez" "org.bluez"
@ -84,10 +90,10 @@ This adds the `environment.fortify` option:
"org.freedesktop.UPower" "org.freedesktop.UPower"
]; ];
}; };
session = config = {
f: filter = true;
f {
talk = [ talk = [
"org.freedesktop.DBus"
"org.freedesktop.FileManager1" "org.freedesktop.FileManager1"
"org.freedesktop.Notifications" "org.freedesktop.Notifications"
"org.freedesktop.ScreenSaver" "org.freedesktop.ScreenSaver"
@ -100,63 +106,74 @@ This adds the `environment.fortify` option:
"org.mpris.MediaPlayer2.org.chromium.Chromium.*" "org.mpris.MediaPlayer2.org.chromium.Chromium.*"
"org.mpris.MediaPlayer2.chromium.*" "org.mpris.MediaPlayer2.chromium.*"
]; ];
call = { }; call = {
broadcast = { }; "org.freedesktop.portal.*" = "*";
};
broadcast = {
"org.freedesktop.portal.*" = "@/org/freedesktop/portal/*";
}; };
}; };
}
{
name = "claws-mail";
id = "org.claws_mail.Claws-Mail";
packages = [ pkgs.claws-mail ];
gpu = false;
capability.pulse = false;
}
{
name = "weechat";
packages = [ pkgs.weechat ];
capability = {
wayland = false;
x11 = false;
dbus = true;
pulse = false;
}; };
}
{
name = "discord";
id = "dev.vencord.Vesktop";
packages = [ pkgs.vesktop ];
share = pkgs.vesktop;
command = "vesktop --ozone-platform-hint=wayland";
userns = true;
mapRealUid = true;
capability.x11 = true;
dbus = {
session =
f:
f {
talk = [ "org.kde.StatusNotifierWatcher" ];
own = [ ];
call = { };
broadcast = { };
}; };
system.filter = true; packages = with pkgs; [
}; weechat
} claws-mail
{ vesktop
name = "looking-glass-client"; chromium
groups = [ "plugdev" ]; ];
extraPaths = [ persistence.directories = [
{ ".config/weechat"
src = "/dev/shm/looking-glass"; ".claws-mail"
write = true; ".config/vesktop"
}
]; ];
extraConfig = { extraConfig = {
programs.looking-glass-client.enable = true; programs.looking-glass-client.enable = true;
}; };
} };
]; };
}; };
} }
``` ```
* `enable` determines whether the module should be enabled or not. Useful when sharing configurations between graphical
and headless systems. Defaults to `false`.
* `user` specifies the privileged user with access to fortified applications.
* `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.
The available options are:
* `packages`, the list of packages to make available in the target user's environment.
* `persistence`, user persistence attribute set passed to impermanence.
* `extraConfig`, extra home-manager configuration for the target user.
* `launchers`, attribute set where the attribute name is the name of the launcher.
The available options are:
* `command`, the command to run as the target user. Defaults to launcher name.
* `dbus.config`, D-Bus proxy custom configuration.
* `dbus.configSystem`, D-Bus system bus custom configuration, null to disable.
* `dbus.id`, D-Bus application id, has no effect if `dbus.config` is set.
* `dbus.mpris`, whether to enable MPRIS defaults, has no effect if `dbus.config` is set.
* `capability.wayland`, whether to share the Wayland socket.
* `capability.x11`, whether to share the X11 socket and allow connection.
* `capability.dbus`, whether to proxy D-Bus.
* `capability.pulse`, whether to share the PulseAudio socket and cookie.
* `share`, package containing desktop/icon files. Defaults to launcher name.
* `method`, the launch method for the sandboxed program, can be `"fortify"`, `"fortify-sudo"`, `"sudo"`.

View File

@ -1,19 +0,0 @@
// Package acl implements simple ACL manipulation via libacl.
package acl
type 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)
}

View File

@ -1,156 +0,0 @@
package acl_test
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os/exec"
"strconv"
)
type (
getFAclInvocation struct {
cmd *exec.Cmd
val []*getFAclResp
pe []error
}
getFAclResp struct {
typ fAclType
cred int32
val fAclPerm
raw []byte
}
fAclPerm uintptr
fAclType uint8
)
const fAclBufSize = 16
const (
fAclPermRead fAclPerm = 1 << iota
fAclPermWrite
fAclPermExecute
)
const (
fAclTypeUser fAclType = iota
fAclTypeGroup
fAclTypeMask
fAclTypeOther
)
func (c *getFAclInvocation) run(name string) error {
if c.cmd != nil {
panic("attempted to run twice")
}
c.cmd = exec.Command("getfacl", "--omit-header", "--absolute-names", "--numeric", name)
scanErr := make(chan error, 1)
if p, err := c.cmd.StdoutPipe(); err != nil {
return err
} else {
go c.parse(p, scanErr)
}
if err := c.cmd.Start(); err != nil {
return err
}
return errors.Join(<-scanErr, c.cmd.Wait())
}
func (c *getFAclInvocation) parse(pipe io.Reader, scanErr chan error) {
c.val = make([]*getFAclResp, 0, 4+fAclBufSize)
s := bufio.NewScanner(pipe)
for s.Scan() {
fields := bytes.SplitN(s.Bytes(), []byte{':'}, 3)
if len(fields) != 3 {
continue
}
resp := getFAclResp{}
switch string(fields[0]) {
case "user":
resp.typ = fAclTypeUser
case "group":
resp.typ = fAclTypeGroup
case "mask":
resp.typ = fAclTypeMask
case "other":
resp.typ = fAclTypeOther
default:
c.pe = append(c.pe, fmt.Errorf("unknown type %s", string(fields[0])))
continue
}
if len(fields[1]) == 0 {
resp.cred = -1
} else {
if cred, err := strconv.Atoi(string(fields[1])); err != nil {
c.pe = append(c.pe, err)
continue
} else {
resp.cred = int32(cred)
if resp.cred < 0 {
c.pe = append(c.pe, fmt.Errorf("credential %d out of range", resp.cred))
continue
}
}
}
if len(fields[2]) != 3 {
c.pe = append(c.pe, fmt.Errorf("invalid perm length %d", len(fields[2])))
continue
} else {
switch fields[2][0] {
case 'r':
resp.val |= fAclPermRead
case '-':
default:
c.pe = append(c.pe, fmt.Errorf("invalid perm %v", fields[2][0]))
continue
}
switch fields[2][1] {
case 'w':
resp.val |= fAclPermWrite
case '-':
default:
c.pe = append(c.pe, fmt.Errorf("invalid perm %v", fields[2][1]))
continue
}
switch fields[2][2] {
case 'x':
resp.val |= fAclPermExecute
case '-':
default:
c.pe = append(c.pe, fmt.Errorf("invalid perm %v", fields[2][2]))
continue
}
}
resp.raw = make([]byte, len(s.Bytes()))
copy(resp.raw, s.Bytes())
c.val = append(c.val, &resp)
}
scanErr <- s.Err()
}
func (r *getFAclResp) String() string {
if r.raw != nil && len(r.raw) > 0 {
return string(r.raw)
}
return "(user-initialised resp value)"
}
func (r *getFAclResp) equals(typ fAclType, cred int32, val fAclPerm) bool {
return r.typ == typ && r.cred == cred && r.val == val
}

View File

@ -1,125 +0,0 @@
package acl_test
import (
"errors"
"os"
"path"
"reflect"
"testing"
"git.gensokyo.uk/security/fortify/acl"
)
const testFileName = "acl.test"
var (
uid = os.Geteuid()
cred = int32(os.Geteuid())
)
func TestUpdatePerm(t *testing.T) {
if os.Getenv("GO_TEST_SKIP_ACL") == "1" {
t.Log("acl test skipped")
t.SkipNow()
}
testFilePath := path.Join(t.TempDir(), testFileName)
if f, err := os.Create(testFilePath); err != nil {
t.Fatalf("Create: error = %v", err)
} else {
if err = f.Close(); err != nil {
t.Fatalf("Close: error = %v", err)
}
}
defer func() {
if err := os.Remove(testFilePath); err != nil {
t.Fatalf("Remove: error = %v", err)
}
}()
cur := getfacl(t, testFilePath)
t.Run("default entry count", func(t *testing.T) {
if len(cur) != 3 {
t.Fatalf("unexpected test file acl length %d", len(cur))
}
})
t.Run("default clear mask", func(t *testing.T) {
if err := acl.UpdatePerm(testFilePath, uid); err != nil {
t.Fatalf("UpdatePerm: error = %v", err)
}
if cur = getfacl(t, testFilePath); len(cur) != 4 {
t.Fatalf("UpdatePerm: %v", cur)
}
})
t.Run("default clear consistency", func(t *testing.T) {
if err := acl.UpdatePerm(testFilePath, uid); err != nil {
t.Fatalf("UpdatePerm: error = %v", err)
}
if val := getfacl(t, testFilePath); !reflect.DeepEqual(val, cur) {
t.Fatalf("UpdatePerm: %v, want %v", val, cur)
}
})
testUpdate(t, testFilePath, "r--", cur, fAclPermRead, acl.Read)
testUpdate(t, testFilePath, "-w-", cur, fAclPermWrite, acl.Write)
testUpdate(t, testFilePath, "--x", cur, fAclPermExecute, acl.Execute)
testUpdate(t, testFilePath, "-wx", cur, fAclPermWrite|fAclPermExecute, acl.Write, acl.Execute)
testUpdate(t, testFilePath, "r-x", cur, fAclPermRead|fAclPermExecute, acl.Read, acl.Execute)
testUpdate(t, testFilePath, "rw-", cur, fAclPermRead|fAclPermWrite, acl.Read, acl.Write)
testUpdate(t, testFilePath, "rwx", cur, fAclPermRead|fAclPermWrite|fAclPermExecute, acl.Read, acl.Write, acl.Execute)
}
func testUpdate(t *testing.T, testFilePath, name string, cur []*getFAclResp, val fAclPerm, perms ...acl.Perm) {
t.Run(name, func(t *testing.T) {
t.Cleanup(func() {
if err := acl.UpdatePerm(testFilePath, uid); err != nil {
t.Fatalf("UpdatePerm: error = %v", err)
}
if v := getfacl(t, testFilePath); !reflect.DeepEqual(v, cur) {
t.Fatalf("UpdatePerm: %v, want %v", v, cur)
}
})
if err := acl.UpdatePerm(testFilePath, uid, perms...); err != nil {
t.Fatalf("UpdatePerm: error = %v", err)
}
r := respByCred(getfacl(t, testFilePath), fAclTypeUser, cred)
if r == nil {
t.Fatalf("UpdatePerm did not add an ACL entry")
}
if !r.equals(fAclTypeUser, cred, val) {
t.Fatalf("UpdatePerm(%s) = %s", name, r)
}
})
}
func getfacl(t *testing.T, name string) []*getFAclResp {
c := new(getFAclInvocation)
if err := c.run(name); err != nil {
t.Fatalf("getfacl: error = %v", err)
}
if len(c.pe) != 0 {
t.Errorf("errors encountered parsing getfacl output\n%s", errors.Join(c.pe...).Error())
}
return c.val
}
func respByCred(v []*getFAclResp, typ fAclType, cred int32) *getFAclResp {
j := -1
for i, r := range v {
if r.typ == typ && r.cred == cred {
if j != -1 {
panic("invalid acl")
}
j = i
}
}
if j == -1 {
return nil
}
return v[j]
}

163
acl/c.go
View File

@ -1,95 +1,50 @@
package acl package acl
import "C"
import ( import (
"errors" "errors"
"runtime" "fmt"
"syscall" "syscall"
"unsafe" "unsafe"
) )
/* //#include <stdlib.h>
#cgo linux pkg-config: libacl //#include <sys/acl.h>
//#include <acl/libacl.h>
#include <stdlib.h> //#cgo linux LDFLAGS: -lacl
#include <sys/acl.h>
#include <acl/libacl.h>
static acl_t _go_acl_get_file(const char *path_p, acl_type_t type) {
acl_t acl = acl_get_file(path_p, type);
free((void *)path_p);
return acl;
}
static int _go_acl_set_file(const char *path_p, acl_type_t type, acl_t acl) {
if (acl_valid(acl) != 0) {
return -1;
}
int ret = acl_set_file(path_p, type, acl);
free((void *)path_p);
return ret;
}
*/
import "C" import "C"
func getFile(name string, t C.acl_type_t) (*ACL, error) { type acl struct {
a, err := C._go_acl_get_file(C.CString(name), t) val C.acl_t
freed bool
}
func aclGetFile(path string, t C.acl_type_t) (*acl, error) {
p := C.CString(path)
a, err := C.acl_get_file(p, t)
C.free(unsafe.Pointer(p))
if errors.Is(err, syscall.ENODATA) { if errors.Is(err, syscall.ENODATA) {
err = nil err = nil
} }
return &acl{val: a, freed: false}, err
return newACL(a), err
} }
func (acl *ACL) setFile(name string, t C.acl_type_t) error { func (a *acl) setFile(path string, t C.acl_type_t) error {
_, err := C._go_acl_set_file(C.CString(name), t, acl.acl) if C.acl_valid(a.val) != 0 {
return fmt.Errorf("invalid acl")
}
p := C.CString(path)
_, err := C.acl_set_file(p, t, a.val)
C.free(unsafe.Pointer(p))
return err return err
} }
func newACL(a C.acl_t) *ACL { func (a *acl) removeEntry(tt C.acl_tag_t, tq int) error {
acl := &ACL{a}
runtime.SetFinalizer(acl, (*ACL).free)
return acl
}
type ACL struct {
acl C.acl_t
}
func (acl *ACL) free() {
C.acl_free(unsafe.Pointer(acl.acl))
// no need for a finalizer anymore
runtime.SetFinalizer(acl, nil)
}
const (
Read = C.ACL_READ
Write = C.ACL_WRITE
Execute = C.ACL_EXECUTE
TypeDefault = C.ACL_TYPE_DEFAULT
TypeAccess = C.ACL_TYPE_ACCESS
UndefinedTag = C.ACL_UNDEFINED_TAG
UserObj = C.ACL_USER_OBJ
User = C.ACL_USER
GroupObj = C.ACL_GROUP_OBJ
Group = C.ACL_GROUP
Mask = C.ACL_MASK
Other = C.ACL_OTHER
)
type (
Perm C.acl_perm_t
)
func (acl *ACL) removeEntry(tt C.acl_tag_t, tq int) error {
var e C.acl_entry_t var e C.acl_entry_t
// get first entry // get first entry
if r, err := C.acl_get_entry(acl.acl, C.ACL_FIRST_ENTRY, &e); err != nil { if r, err := C.acl_get_entry(a.val, C.ACL_FIRST_ENTRY, &e); err != nil {
return err return err
} else if r == 0 { } else if r == 0 {
// return on acl with no entries // return on acl with no entries
@ -97,7 +52,7 @@ func (acl *ACL) removeEntry(tt C.acl_tag_t, tq int) error {
} }
for { for {
if r, err := C.acl_get_entry(acl.acl, C.ACL_NEXT_ENTRY, &e); err != nil { if r, err := C.acl_get_entry(a.val, C.ACL_NEXT_ENTRY, &e); err != nil {
return err return err
} else if r == 0 { } else if r == 0 {
// return on drained acl // return on drained acl
@ -129,68 +84,16 @@ func (acl *ACL) removeEntry(tt C.acl_tag_t, tq int) error {
// delete on match // delete on match
if t == tt && q == tq { if t == tt && q == tq {
_, err := C.acl_delete_entry(acl.acl, e) _, err := C.acl_delete_entry(a.val, e)
return err return err
} }
} }
} }
func UpdatePerm(name string, uid int, perms ...Perm) error { func (a *acl) free() {
// read acl from file if a.freed {
a, err := getFile(name, TypeAccess) panic("acl already freed")
if err != nil {
return err
} }
// free acl on return if get is successful C.acl_free(unsafe.Pointer(a.val))
defer a.free() a.freed = true
// remove existing entry
if err = a.removeEntry(User, uid); err != nil {
return err
}
// create new entry if perms are passed
if len(perms) > 0 {
// create new acl entry
var e C.acl_entry_t
if _, err = C.acl_create_entry(&a.acl, &e); err != nil {
return err
}
// get perm set of new entry
var p C.acl_permset_t
if _, err = C.acl_get_permset(e, &p); err != nil {
return err
}
// add target perms
for _, perm := range perms {
if _, err = C.acl_add_perm(p, C.acl_perm_t(perm)); err != nil {
return err
}
}
// set perm set to new entry
if _, err = C.acl_set_permset(e, p); err != nil {
return err
}
// set user tag to new entry
if _, err = C.acl_set_tag_type(e, User); err != nil {
return err
}
// set qualifier (uid) to new entry
if _, err = C.acl_set_qualifier(e, unsafe.Pointer(&uid)); err != nil {
return err
}
}
// calculate mask after update
if _, err = C.acl_calc_mask(&a.acl); err != nil {
return err
}
// write acl to file
return a.setFile(name, TypeAccess)
} }

88
acl/export.go Normal file
View File

@ -0,0 +1,88 @@
package acl
import "unsafe"
//#include <stdlib.h>
//#include <sys/acl.h>
//#include <acl/libacl.h>
//#cgo linux LDFLAGS: -lacl
import "C"
const (
Read = C.ACL_READ
Write = C.ACL_WRITE
Execute = C.ACL_EXECUTE
TypeDefault = C.ACL_TYPE_DEFAULT
TypeAccess = C.ACL_TYPE_ACCESS
UndefinedTag = C.ACL_UNDEFINED_TAG
UserObj = C.ACL_USER_OBJ
User = C.ACL_USER
GroupObj = C.ACL_GROUP_OBJ
Group = C.ACL_GROUP
Mask = C.ACL_MASK
Other = C.ACL_OTHER
)
type Perm C.acl_perm_t
func UpdatePerm(path string, uid int, perms ...Perm) error {
// read acl from file
a, err := aclGetFile(path, TypeAccess)
if err != nil {
return err
}
// free acl on return if get is successful
defer a.free()
// remove existing entry
if err = a.removeEntry(User, uid); err != nil {
return err
}
// create new entry if perms are passed
if len(perms) > 0 {
// create new acl entry
var e C.acl_entry_t
if _, err = C.acl_create_entry(&a.val, &e); err != nil {
return err
}
// get perm set of new entry
var p C.acl_permset_t
if _, err = C.acl_get_permset(e, &p); err != nil {
return err
}
// add target perms
for _, perm := range perms {
if _, err = C.acl_add_perm(p, C.acl_perm_t(perm)); err != nil {
return err
}
}
// set perm set to new entry
if _, err = C.acl_set_permset(e, p); err != nil {
return err
}
// set user tag to new entry
if _, err = C.acl_set_tag_type(e, User); err != nil {
return err
}
// set qualifier (uid) to new entry
if _, err = C.acl_set_qualifier(e, unsafe.Pointer(&uid)); err != nil {
return err
}
}
// calculate mask after update
if _, err = C.acl_calc_mask(&a.val); err != nil {
return err
}
// write acl to file
return a.setFile(path, TypeAccess)
}

View File

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

View File

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

View File

@ -1,137 +0,0 @@
package shim
import (
"encoding/gob"
"errors"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
shim0 "git.gensokyo.uk/security/fortify/cmd/fshim/ipc"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/proc"
)
const shimSetupTimeout = 5 * time.Second
// used by the parent process
type Shim struct {
// user switcher process
cmd *exec.Cmd
// uid of shim target user
uid uint32
// string representation of application id
aid string
// string representation of supplementary group ids
supp []string
// fallback exit notifier with error returned killing the process
killFallback chan error
// shim setup payload
payload *shim0.Payload
}
func New(uid uint32, aid string, supp []string, payload *shim0.Payload) *Shim {
return &Shim{uid: uid, aid: aid, supp: supp, payload: payload}
}
func (s *Shim) String() string {
if s.cmd == nil {
return "(unused shim manager)"
}
return s.cmd.String()
}
func (s *Shim) Unwrap() *exec.Cmd {
return s.cmd
}
func (s *Shim) WaitFallback() chan error {
return s.killFallback
}
func (s *Shim) Start() (*time.Time, error) {
// start user switcher process and save time
var fsu string
if p, ok := internal.Check(internal.Fsu); !ok {
fmsg.Fatal("invalid fsu path, this copy of fshim is not compiled correctly")
panic("unreachable")
} else {
fsu = p
}
s.cmd = exec.Command(fsu)
var encoder *gob.Encoder
if fd, e, err := proc.Setup(&s.cmd.ExtraFiles); err != nil {
return nil, fmsg.WrapErrorSuffix(err,
"cannot create shim setup pipe:")
} else {
encoder = e
s.cmd.Env = []string{
shim0.Env + "=" + strconv.Itoa(fd),
"FORTIFY_APP_ID=" + s.aid,
}
}
if len(s.supp) > 0 {
fmsg.VPrintf("attaching supplementary group ids %s", s.supp)
s.cmd.Env = append(s.cmd.Env, "FORTIFY_GROUPS="+strings.Join(s.supp, " "))
}
s.cmd.Stdin, s.cmd.Stdout, s.cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
s.cmd.Dir = "/"
// pass sync fd if set
if s.payload.Bwrap.Sync() != nil {
fd := proc.ExtraFile(s.cmd, s.payload.Bwrap.Sync())
s.payload.Sync = &fd
}
fmsg.VPrintln("starting shim via fsu:", s.cmd)
fmsg.Suspend() // withhold messages to stderr
if err := s.cmd.Start(); err != nil {
return nil, fmsg.WrapErrorSuffix(err,
"cannot start fsu:")
}
startTime := time.Now().UTC()
// kill shim if something goes wrong and an error is returned
s.killFallback = make(chan error, 1)
killShim := func() {
if err := s.cmd.Process.Signal(os.Interrupt); err != nil {
s.killFallback <- err
}
}
defer func() { killShim() }()
// take alternative exit path on signal
sig := make(chan os.Signal, 2)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
go func() {
v := <-sig
fmsg.Printf("got %s after program start", v)
s.killFallback <- nil
signal.Ignore(syscall.SIGINT, syscall.SIGTERM)
}()
shimErr := make(chan error)
go func() { shimErr <- encoder.Encode(s.payload) }()
select {
case err := <-shimErr:
if err != nil {
return &startTime, fmsg.WrapErrorSuffix(err,
"cannot transmit shim config:")
}
killShim = func() {}
case <-time.After(shimSetupTimeout):
return &startTime, fmsg.WrapError(errors.New("timed out waiting for shim"),
"timed out waiting for shim")
}
return &startTime, nil
}

View File

@ -1,145 +0,0 @@
package main
import (
"errors"
"os"
"path"
"strconv"
"syscall"
init0 "git.gensokyo.uk/security/fortify/cmd/finit/ipc"
shim "git.gensokyo.uk/security/fortify/cmd/fshim/ipc"
"git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/proc"
)
// everything beyond this point runs as unconstrained target user
// proceed with caution!
func main() {
// sharing stdout with fortify
// USE WITH CAUTION
fmsg.SetPrefix("shim")
// setting this prevents ptrace
if err := internal.PR_SET_DUMPABLE__SUID_DUMP_DISABLE(); err != nil {
fmsg.Fatalf("cannot set SUID_DUMP_DISABLE: %s", err)
panic("unreachable")
}
// re-exec
if len(os.Args) > 0 && (os.Args[0] != "fshim" || len(os.Args) != 1) && path.IsAbs(os.Args[0]) {
if err := syscall.Exec(os.Args[0], []string{"fshim"}, os.Environ()); err != nil {
fmsg.Println("cannot re-exec self:", err)
// continue anyway
}
}
// check path to finit
var finitPath string
if p, ok := internal.Path(internal.Finit); !ok {
fmsg.Fatal("invalid finit path, this copy of fshim is not compiled correctly")
} else {
finitPath = p
}
// receive setup payload
var (
payload shim.Payload
closeSetup func() error
)
if f, err := proc.Receive(shim.Env, &payload); err != nil {
if errors.Is(err, proc.ErrInvalid) {
fmsg.Fatal("invalid config descriptor")
}
if errors.Is(err, proc.ErrNotSet) {
fmsg.Fatal("FORTIFY_SHIM not set")
}
fmsg.Fatalf("cannot decode shim setup payload: %v", err)
panic("unreachable")
} else {
fmsg.SetVerbose(payload.Verbose)
closeSetup = f
}
if payload.Bwrap == nil {
fmsg.Fatal("bwrap config not supplied")
}
// restore bwrap sync fd
if payload.Sync != nil {
payload.Bwrap.SetSync(os.NewFile(*payload.Sync, "sync"))
}
// close setup socket
if err := closeSetup(); err != nil {
fmsg.Println("cannot close setup pipe:", 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[1]
} else {
// no argv, look up shell instead
var ok bool
if payload.Bwrap.SetEnv == nil {
fmsg.Fatal("no command was specified and environment is unset")
}
if ic.Argv0, ok = payload.Bwrap.SetEnv["SHELL"]; !ok {
fmsg.Fatal("no command was specified and $SHELL was unset")
}
ic.Argv = []string{ic.Argv0}
}
conf := payload.Bwrap
var extraFiles []*os.File
// serve setup payload
if fd, encoder, err := proc.Setup(&extraFiles); err != nil {
fmsg.Fatalf("cannot pipe: %v", err)
} else {
conf.SetEnv[init0.Env] = strconv.Itoa(fd)
go func() {
fmsg.VPrintln("transmitting config to init")
if err = encoder.Encode(&ic); err != nil {
fmsg.Fatalf("cannot transmit init config: %v", err)
}
}()
}
helper.BubblewrapName = payload.Exec[0] // resolved bwrap path by parent
if b, err := helper.NewBwrap(conf, nil, finitPath,
func(int, int) []string { return make([]string, 0) }); err != nil {
fmsg.Fatalf("malformed sandbox config: %v", err)
} else {
cmd := b.Unwrap()
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.ExtraFiles = extraFiles
if fmsg.Verbose() {
fmsg.VPrintln("bwrap args:", conf.Args())
}
// run and pass through exit code
if err = b.Start(); err != nil {
fmsg.Fatalf("cannot start target process: %v", 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)
}
}
}

View File

@ -1,154 +0,0 @@
package main
import (
"bytes"
"fmt"
"log"
"os"
"path"
"slices"
"strconv"
"strings"
"syscall"
)
const (
compPoison = "INVALIDINVALIDINVALIDINVALIDINVALID"
fsuConfFile = "/etc/fsurc"
envShim = "FORTIFY_SHIM"
envAID = "FORTIFY_APP_ID"
envGroups = "FORTIFY_GROUPS"
PR_SET_NO_NEW_PRIVS = 0x26
)
var (
Fmain = compPoison
Fshim = compPoison
)
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")
}
var fmain, fshim string
if p, ok := checkPath(Fmain); !ok {
log.Fatal("invalid fortify path, this copy of fsu is not compiled correctly")
} else {
fmain = p
}
if p, ok := checkPath(Fshim); !ok {
log.Fatal("invalid fshim path, this copy of fsu is not compiled correctly")
} else {
fshim = p
}
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 != fmain {
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
}
// allowed aid range 0 to 9999
if as, ok := os.LookupEnv(envAID); !ok {
log.Fatal("FORTIFY_APP_ID not set")
} else if aid, err := parseUint32Fast(as); err != nil || aid < 0 || aid > 9999 {
log.Fatal("invalid aid")
} else {
uid += aid
}
// pass through setup fd to shim
var shimSetupFd string
if s, ok := os.LookupEnv(envShim); !ok {
// fortify requests target uid
// print resolved uid and exit
fmt.Print(uid)
os.Exit(0)
} else if len(s) != 1 || s[0] > '9' || s[0] < '3' {
log.Fatal("FORTIFY_SHIM holds an invalid value")
} else {
shimSetupFd = s
}
// supplementary groups
var suppGroups, suppCurrent []int
if gs, ok := os.LookupEnv(envGroups); ok {
if cur, err := os.Getgroups(); err != nil {
log.Fatalf("cannot get groups: %v", err)
} else {
suppCurrent = cur
}
// parse space-separated list of group ids
gss := bytes.Split([]byte(gs), []byte{' '})
suppGroups = make([]int, len(gss)+1)
for i, s := range gss {
if gid, err := strconv.Atoi(string(s)); err != nil {
log.Fatalf("cannot parse %q: %v", string(s), err)
} else if gid > 0 && gid != uid && gid != os.Getgid() && slices.Contains(suppCurrent, gid) {
suppGroups[i] = gid
} else {
log.Fatalf("invalid gid %d", gid)
}
}
suppGroups[len(suppGroups)-1] = uid
} else {
suppGroups = []int{uid}
}
// final bounds check to catch any bugs
if uid < 1000000 || uid >= 2000000 {
panic("uid out of bounds")
}
// careful! users in the allowlist is effectively allowed to drop groups via fsu
if err := syscall.Setresgid(uid, uid, uid); err != nil {
log.Fatalf("cannot set gid: %v", err)
}
if err := syscall.Setgroups(suppGroups); err != nil {
log.Fatalf("cannot set supplementary groups: %v", err)
}
if err := syscall.Setresuid(uid, uid, uid); err != nil {
log.Fatalf("cannot set uid: %v", err)
}
if _, _, errno := syscall.AllThreadsSyscall(syscall.SYS_PRCTL, PR_SET_NO_NEW_PRIVS, 1, 0); errno != 0 {
log.Fatalf("cannot set no_new_privs flag: %s", errno.Error())
}
if err := syscall.Exec(fshim, []string{"fshim"}, []string{envShim + "=" + shimSetupFd}); err != nil {
log.Fatalf("cannot start shim: %v", err)
}
panic("unreachable")
}
func checkPath(p string) (string, bool) {
return p, p != compPoison && p != "" && path.IsAbs(p)
}

View File

@ -1,77 +0,0 @@
package main
import (
"bufio"
"errors"
"fmt"
"log"
"os"
"strings"
"syscall"
)
func parseUint32Fast(s string) (int, error) {
sLen := len(s)
if sLen < 1 {
return -1, errors.New("zero length string")
}
if sLen > 10 {
return -1, errors.New("string too long")
}
n := 0
for i, ch := range []byte(s) {
ch -= '0'
if ch > 9 {
return -1, fmt.Errorf("invalid character '%s' at index %d", string([]byte{ch}), i)
}
n = n*10 + int(ch)
}
return n, nil
}
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 = parseUint32Fast(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 = parseUint32Fast(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
}
}

View File

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

View File

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

View File

@ -1,51 +0,0 @@
#compdef fortify
_fortify_app() {
_path_files -g "*.(json|ftfy)"
}
_fortify_run() {
_arguments \
'--id[App ID, leave empty to disable security context app_id]:id' \
'-a[Fortify application ID]: :_numbers' \
'-g[Groups inherited by the app process]: :_groups' \
'-d[Application home directory]: :_files -/' \
'-u[Passwd name within sandbox]: :_users' \
'--wayland[Share Wayland socket]' \
'-X[Share X11 socket and allow connection]' \
'--dbus[Proxy D-Bus connection]' \
'--pulse[Share PulseAudio socket and cookie]' \
'--dbus-config[Path to D-Bus proxy config file]: :_files -g "*.json"' \
'--dbus-system[Path to system D-Bus proxy config file]: :_files -g "*.json"' \
'--mpris[Allow owning MPRIS D-Bus path]' \
'--dbus-log[Force logging in the D-Bus proxy]'
}
(( $+functions[_fortify_commands] )) || _fortify_commands()
{
local -a _fortify_cmds
_fortify_cmds=(
"app:Launch app defined by the specified config file"
"run:Configure and start a permissive default sandbox"
"ps:List active apps and their state"
"version:Show fortify version"
"license:Show full license text"
"template:Produce a config template"
"help:Show help message"
)
if (( CURRENT == 1 )); then
_describe -t commands 'fortify command' _fortify_cmds || compadd "$@"
else
local curcontext="$curcontext"
cmd="${${_fortify_cmds[(r)$words[1]:*]%%:*}}"
if (( $+functions[_fortify_$cmd] )); then
_fortify_$cmd
else
_message "no more options"
fi
fi
}
_arguments -C \
'-v[Verbose output]' \
'*::fortify command:_fortify_commands'

136
config.go Normal file
View File

@ -0,0 +1,136 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/app"
"git.ophivana.moe/cat/fortify/internal/state"
)
var (
printTemplate bool
confPath string
dbusConfigSession string
dbusConfigSystem string
dbusID string
mpris bool
dbusVerbose bool
userName string
enablements [state.EnableLength]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[state.EnableWayland], "wayland", false, "Share Wayland socket")
flag.BoolVar(&enablements[state.EnableX], "X", false, "Share X11 socket and allow connection")
flag.BoolVar(&enablements[state.EnableDBus], "dbus", false, "Proxy D-Bus connection")
flag.BoolVar(&enablements[state.EnablePulse], "pulse", false, "Share PulseAudio socket and cookie")
}
func init() {
methodHelpString := "Method of launching the child process, can be one of \"sudo\""
if internal.SdBootedV {
methodHelpString += ", \"systemd\""
}
flag.StringVar(&launchMethodText, "method", "sudo", methodHelpString)
}
func tryTemplate() {
if printTemplate {
if s, err := json.MarshalIndent(app.Template(), "", " "); err != nil {
fatalf("cannot generate template: %v", err)
panic("unreachable")
} else {
fmt.Println(string(s))
}
os.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 {
fatalf("cannot access config file '%s': %s\n", confPath, err)
panic("unreachable")
} else if err = json.NewDecoder(f).Decode(&c); err != nil {
fatalf("cannot parse config file '%s': %s\n", 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 := state.Enablement(0); i < state.EnableLength; i++ {
if enablements[i] {
config.Confinement.Enablements.Set(i)
}
}
// parse D-Bus config file from flags if applicable
if enablements[state.EnableDBus] {
if dbusConfigSession == "builtin" {
config.Confinement.SessionBus = dbus.NewConfig(dbusID, true, mpris)
} else {
if c, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil {
fatalf("cannot load session bus proxy config from %q: %s\n", dbusConfigSession, err)
} else {
config.Confinement.SessionBus = c
}
}
// system bus proxy is optional
if dbusConfigSystem != "nil" {
if c, err := dbus.NewConfigFromFile(dbusConfigSystem); err != nil {
fatalf("cannot load system bus proxy config from %q: %s\n", 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.gensokyo.uk/security/fortify/dbus" "git.ophivana.moe/cat/fortify/dbus"
) )
func TestConfig_Args(t *testing.T) { func TestConfig_Args(t *testing.T) {

View File

@ -1,4 +1,3 @@
// Package dbus wraps xdg-dbus-proxy and implements configuration and sandboxing of the underlying helper process.
package dbus package dbus
import ( import (

View File

@ -5,8 +5,8 @@ import (
"strings" "strings"
"testing" "testing"
"git.gensokyo.uk/security/fortify/dbus" "git.ophivana.moe/cat/fortify/dbus"
"git.gensokyo.uk/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
) )
func TestNew(t *testing.T) { func TestNew(t *testing.T) {

View File

@ -6,8 +6,8 @@ import (
"io" "io"
"sync" "sync"
"git.gensokyo.uk/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
"git.gensokyo.uk/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.gensokyo.uk/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.ophivana.moe/cat/fortify/helper/bwrap"
"git.gensokyo.uk/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,9 +79,11 @@ 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))
@ -101,9 +103,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.gensokyo.uk/security/fortify/dbus" "git.ophivana.moe/cat/fortify/dbus"
) )
var samples = []dbusTestCase{ var samples = []dbusTestCase{

View File

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

1
dist/fsurc.default vendored
View File

@ -1 +0,0 @@
1000 0

10
dist/install.sh vendored
View File

@ -1,10 +0,0 @@
#!/bin/sh
cd "$(dirname -- "$0")" || exit 1
install -vDm0755 "bin/fortify" "${FORTIFY_INSTALL_PREFIX}/usr/bin/fortify"
install -vDm0755 "bin/fshim" "${FORTIFY_INSTALL_PREFIX}/usr/libexec/fortify/fshim"
install -vDm0755 "bin/finit" "${FORTIFY_INSTALL_PREFIX}/usr/libexec/fortify/finit"
install -vDm0755 "bin/fuserdb" "${FORTIFY_INSTALL_PREFIX}/usr/libexec/fortify/fuserdb"
install -vDm6511 "bin/fsu" "${FORTIFY_INSTALL_PREFIX}/usr/bin/fsu"
install -vDm0400 "fsurc.default" "${FORTIFY_INSTALL_PREFIX}/etc/fsurc"

19
dist/release.sh vendored
View File

@ -1,19 +0,0 @@
#!/bin/sh -e
cd "$(dirname -- "$0")/.."
VERSION="${FORTIFY_VERSION:-untagged}"
pname="fortify-${VERSION}"
out="dist/${pname}"
mkdir -p "${out}"
cp "README.md" "dist/fsurc.default" "dist/install.sh" "${out}"
go build -v -o "${out}/bin/" -ldflags "-s -w
-X git.gensokyo.uk/security/fortify/internal.Version=${VERSION}
-X git.gensokyo.uk/security/fortify/internal.Fsu=/usr/bin/fsu
-X git.gensokyo.uk/security/fortify/internal.Finit=/usr/libexec/fortify/finit
-X main.Fmain=/usr/bin/fortify
-X main.Fshim=/usr/libexec/fortify/fshim" ./...
rm -f "./${out}.tar.gz" && tar -C dist -czf "${out}.tar.gz" "${pname}"
rm -rf "./${out}"
sha512sum "${out}.tar.gz" > "${out}.tar.gz.sha512"

View File

@ -2,21 +2,22 @@ package main
import ( import (
"errors" "errors"
"fmt"
"os"
"git.gensokyo.uk/security/fortify/internal/app" "git.ophivana.moe/cat/fortify/internal/app"
"git.gensokyo.uk/security/fortify/internal/fmsg"
) )
func logWaitError(err error) { func logWaitError(err error) {
var e *fmsg.BaseError var e *app.BaseError
if !fmsg.AsBaseError(err, &e) { if !app.AsBaseError(err, &e) {
fmsg.Println("wait failed:", err) fmt.Println("fortify: wait failed:", err)
} else { } else {
// Wait only returns either *app.ProcessError or *app.StateStoreError wrapped in a *app.BaseError // Wait only returns either *app.ProcessError or *app.StateStoreError wrapped in a *app.BaseError
var se *app.StateStoreError var se *app.StateStoreError
if !errors.As(err, &se) { if !errors.As(err, &se) {
// does not need special handling // does not need special handling
fmsg.Print(e.Message()) fmt.Print("fortify: " + e.Message())
} else { } else {
// inner error are either unwrapped store errors // inner error are either unwrapped store errors
// or joined errors returned by *appSealTx revert // or joined errors returned by *appSealTx revert
@ -24,19 +25,19 @@ func logWaitError(err error) {
var ej app.RevertCompoundError var ej app.RevertCompoundError
if !errors.As(se.InnerErr, &ej) { if !errors.As(se.InnerErr, &ej) {
// does not require special handling // does not require special handling
fmsg.Print(e.Message()) fmt.Print("fortify: " + e.Message())
} else { } else {
errs := ej.Unwrap() errs := ej.Unwrap()
// every error here is wrapped in *app.BaseError // every error here is wrapped in *app.BaseError
for _, ei := range errs { for _, ei := range errs {
var eb *fmsg.BaseError var eb *app.BaseError
if !errors.As(ei, &eb) { if !errors.As(ei, &eb) {
// unreachable // unreachable
fmsg.Println("invalid error type returned by revert:", ei) fmt.Println("fortify: invalid error type returned by revert:", ei)
} else { } else {
// print inner *app.BaseError message // print inner *app.BaseError message
fmsg.Print(eb.Message()) fmt.Print("fortify: " + eb.Message())
} }
} }
} }
@ -45,11 +46,16 @@ func logWaitError(err error) {
} }
func logBaseError(err error, message string) { func logBaseError(err error, message string) {
var e *fmsg.BaseError var e *app.BaseError
if fmsg.AsBaseError(err, &e) { if app.AsBaseError(err, &e) {
fmsg.Print(e.Message()) fmt.Print("fortify: " + e.Message())
} else { } else {
fmsg.Println(message, err) fmt.Println(message, err)
} }
} }
func fatalf(format string, a ...any) {
fmt.Printf("fortify: "+format, a...)
os.Exit(1)
}

30
flake.lock generated
View File

@ -1,45 +1,23 @@
{ {
"nodes": { "nodes": {
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1733951536,
"narHash": "sha256-Zb5ZCa7Xj+0gy5XVXINTSr71fCfAv+IKtmIXNrykT54=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "1318c3f3b068cdcea922fa7c1a0a1f0c96c22f5f",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "release-24.11",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1734298236, "lastModified": 1725361206,
"narHash": "sha256-aWhhqY44xBjMoO9r5fyPp5u8tqUNWRZ/m/P+abMSs5c=", "narHash": "sha256-/HTUg+kMaqBPGrcQBYboAMsQHIWIkuKRDldss/035Hc=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "eb919d9300b6a18f8583f58aef16db458fbd7bec", "rev": "2830c7c930311397d94c0b86a359c865c081c875",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-24.11-small", "ref": "nixos-unstable-small",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"home-manager": "home-manager",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
} }

113
flake.nix
View File

@ -2,20 +2,11 @@
description = "fortify sandbox tool and nixos module"; description = "fortify sandbox tool and nixos module";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11-small"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable-small";
home-manager = {
url = "github:nix-community/home-manager/release-24.11";
inputs.nixpkgs.follows = "nixpkgs";
};
}; };
outputs = outputs =
{ { self, nixpkgs }:
self,
nixpkgs,
home-manager,
}:
let let
supportedSystems = [ supportedSystems = [
"aarch64-linux" "aarch64-linux"
@ -29,55 +20,6 @@
{ {
nixosModules.fortify = import ./nixos.nix; nixosModules.fortify = import ./nixos.nix;
checks = forAllSystems (
system:
let
pkgs = nixpkgsFor.${system};
inherit (pkgs)
runCommandLocal
callPackage
nixfmt-rfc-style
deadnix
statix
;
in
{
check-formatting =
runCommandLocal "check-formatting" { nativeBuildInputs = [ nixfmt-rfc-style ]; }
''
cd ${./.}
echo "running nixfmt..."
nixfmt --check .
touch $out
'';
check-lint =
runCommandLocal "check-lint"
{
nativeBuildInputs = [
deadnix
statix
];
}
''
cd ${./.}
echo "running deadnix..."
deadnix --fail
echo "running statix..."
statix check .
touch $out
'';
nixos-tests = callPackage ./test.nix { inherit system self home-manager; };
}
);
packages = forAllSystems ( packages = forAllSystems (
system: system:
let let
@ -92,61 +34,10 @@
devShells = forAllSystems (system: { devShells = forAllSystems (system: {
default = nixpkgsFor.${system}.mkShell { default = nixpkgsFor.${system}.mkShell {
buildInputs = with nixpkgsFor.${system}; self.packages.${system}.fortify.buildInputs;
};
fhs = nixpkgsFor.${system}.buildFHSEnv {
pname = "fortify-fhs";
inherit (self.packages.${system}.fortify) version;
targetPkgs =
pkgs: with pkgs; [
go
gcc
pkg-config
acl
wayland
wayland-scanner
wayland-protocols
xorg.libxcb
];
extraOutputsToInstall = [ "dev" ];
profile = ''
export PKG_CONFIG_PATH="/usr/share/pkgconfig:$PKG_CONFIG_PATH"
'';
};
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 ];
}; };
generateDoc =
let
pkgs = nixpkgsFor.${system};
inherit (pkgs) lib;
doc =
let
eval = lib.evalModules {
specialArgs = {
inherit pkgs;
};
modules = [ ./options.nix ];
};
cleanEval = lib.filterAttrsRecursive (n: _: n != "_module") eval;
in
pkgs.nixosOptionsDoc { inherit (cleanEval) options; };
docText = pkgs.runCommand "fortify-module-docs.md" { } ''
cat ${doc.optionsCommonMark} > $out
sed -i '/*Declared by:*/,+1 d' $out
'';
in
nixpkgsFor.${system}.mkShell {
shellHook = ''
exec cat ${docText} > options.md
'';
};
}); });
}; };
} }

View File

@ -1,48 +0,0 @@
package fst
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
)
type ID [16]byte
var (
ErrInvalidLength = errors.New("string representation must have a length of 32")
)
func (a *ID) String() string {
return hex.EncodeToString(a[:])
}
func NewAppID(id *ID) error {
_, err := rand.Read(id[:])
return err
}
func ParseAppID(id *ID, s string) error {
if len(s) != 32 {
return ErrInvalidLength
}
for i, b := range s {
if b < '0' || b > 'f' {
return fmt.Errorf("invalid char %q at byte %d", b, i)
}
v := uint8(b)
if v > '9' {
v = 10 + v - 'a'
} else {
v -= '0'
}
if i%2 == 0 {
v <<= 4
}
id[i/2] += v
}
return nil
}

View File

@ -1,63 +0,0 @@
package fst_test
import (
"errors"
"testing"
"git.gensokyo.uk/security/fortify/fst"
)
func TestParseAppID(t *testing.T) {
t.Run("bad length", func(t *testing.T) {
if err := fst.ParseAppID(new(fst.ID), "meow"); !errors.Is(err, fst.ErrInvalidLength) {
t.Errorf("ParseAppID: error = %v, wantErr = %v", err, fst.ErrInvalidLength)
}
})
t.Run("bad byte", func(t *testing.T) {
wantErr := "invalid char '\\n' at byte 15"
if err := fst.ParseAppID(new(fst.ID), "02bc7f8936b2af6\n\ne2535cd71ef0bb7"); err == nil || err.Error() != wantErr {
t.Errorf("ParseAppID: error = %v, wantErr = %v", err, wantErr)
}
})
t.Run("fuzz 16 iterations", func(t *testing.T) {
for i := 0; i < 16; i++ {
testParseAppIDWithRandom(t)
}
})
}
func FuzzParseAppID(f *testing.F) {
for i := 0; i < 16; i++ {
id := new(fst.ID)
if err := fst.NewAppID(id); err != nil {
panic(err.Error())
}
f.Add(id[0], id[1], id[2], id[3], id[4], id[5], id[6], id[7], id[8], id[9], id[10], id[11], id[12], id[13], id[14], id[15])
}
f.Fuzz(func(t *testing.T, b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15 byte) {
testParseAppID(t, &fst.ID{b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15})
})
}
func testParseAppIDWithRandom(t *testing.T) {
id := new(fst.ID)
if err := fst.NewAppID(id); err != nil {
t.Fatalf("cannot generate app ID: %v", err)
}
testParseAppID(t, id)
}
func testParseAppID(t *testing.T, id *fst.ID) {
s := id.String()
got := new(fst.ID)
if err := fst.ParseAppID(got, s); err != nil {
t.Fatalf("cannot parse app ID: %v", err)
}
if *got != *id {
t.Fatalf("ParseAppID(%#v) = \n%#v, want \n%#v", s, got, id)
}
}

View File

@ -1,2 +0,0 @@
// Package fst exports shared fortify types.
package fst

2
go.mod
View File

@ -1,3 +1,3 @@
module git.gensokyo.uk/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.gensokyo.uk/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

@ -3,13 +3,11 @@ package helper
import ( import (
"errors" "errors"
"io" "io"
"os"
"os/exec" "os/exec"
"strconv" "strconv"
"sync" "sync"
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.ophivana.moe/cat/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/proc"
) )
// BubblewrapName is the file name or path to bubblewrap. // BubblewrapName is the file name or path to bubblewrap.
@ -21,8 +19,6 @@ type bubblewrap struct {
// bwrap pipes // bwrap pipes
p *pipes p *pipes
// sync pipe
sync *os.File
// 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
@ -76,10 +72,6 @@ func (b *bubblewrap) StartNotify(ready chan error) error {
b.Cmd.Env = append(b.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=-1") b.Cmd.Env = append(b.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=-1")
} }
if b.sync != nil {
b.Cmd.Args = append(b.Cmd.Args, "--sync-fd", strconv.Itoa(int(proc.ExtraFile(b.Cmd, b.sync))))
}
if err := b.Cmd.Start(); err != nil { if err := b.Cmd.Start(); err != nil {
return err return err
} }
@ -139,7 +131,6 @@ func NewBwrap(conf *bwrap.Config, wt io.WriterTo, name string, argF func(argsFD,
b.p = &pipes{args: args} b.p = &pipes{args: args}
} }
b.sync = conf.Sync()
b.argF = argF b.argF = argF
b.name = name b.name = name
if wt != nil { if wt != nil {

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,77 @@
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()
g := c.interfaceArgs()
s := c.stringArgs()
p := c.pairArgs()
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 g {
argc += len(arg) * 3
}
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 g {
for _, v := range arg {
if v.Later() {
continue
}
args = append(args, v.Value(interfaceArgs[i])...)
}
}
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 {
if !v.Later() {
continue
}
args = append(args, v.Value(interfaceArgs[i])...)
}
}
return
} }
type Config struct { type Config struct {
@ -51,12 +114,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 []string `json:"proc,omitempty"`
// mount new dev in sandbox
// (--dev DEST)
DevTmpfs []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 []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)
@ -68,16 +172,13 @@ type Config struct {
// (--as-pid-1) // (--as-pid-1)
AsInit bool `json:"as_init"` AsInit bool `json:"as_init"`
// keep this fd open while sandbox is running
// (--sync-fd FD)
sync *os.File
/* unmapped options include: /* unmapped options include:
--unshare-user-try Create new user namespace if possible else continue by skipping it --unshare-user-try Create new user namespace if possible else continue by skipping it
--unshare-cgroup-try Create new cgroup namespace if possible else continue by skipping it --unshare-cgroup-try Create new cgroup namespace if possible else continue by skipping it
--userns FD Use this user namespace (cannot combine with --unshare-user) --userns FD Use this user namespace (cannot combine with --unshare-user)
--userns2 FD After setup switch to this user namespace, only useful with --userns --userns2 FD After setup switch to this user namespace, only useful with --userns
--pidns FD Use this pid namespace (as parent namespace if using --unshare-pid) --pidns FD Use this pid namespace (as parent namespace if using --unshare-pid)
--sync-fd FD Keep this fd open while sandbox is running
--exec-label LABEL Exec label for the sandbox --exec-label LABEL Exec label for the sandbox
--file-label LABEL File label for temporary sandbox content --file-label LABEL File label for temporary sandbox content
--file FD DEST Copy from FD to destination DEST --file FD DEST Copy from FD to destination DEST
@ -95,12 +196,6 @@ type Config struct {
among which --args is used internally for passing arguments */ among which --args is used internally for passing arguments */
} }
// Sync keep this fd open while sandbox is running
// (--sync-fd FD)
func (c *Config) Sync() *os.File {
return c.sync
}
type UnshareConfig struct { type UnshareConfig struct {
// (--unshare-user) // (--unshare-user)
// create new user namespace // create new user namespace
@ -122,34 +217,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)
@ -159,47 +226,62 @@ 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)
Later() bool
} }
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 {
// append this at the end of the argument stream
Last bool
// 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]) Later() bool {
return p.Last
}
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)
} }
}
switch v := any(p.Path).(type) {
func (t *TmpfsConfig) Append(args *[]string) { case string:
if t.Size > 0 { args = append(args, arg, v)
*args = append(*args, intArgs[Size], strconv.Itoa(t.Size)) return
} case [2]string:
*args = append(*args, awkwardArgs[Tmpfs], t.Dir) args = append(args, arg, v[0], v[1])
} return
case TmpfsConfig:
type SymlinkConfig [2]string if arg != "--tmpfs" {
panic("unreachable")
func (s SymlinkConfig) Path() string { }
return s[1]
} if v.Size > 0 {
args = append(args, "--size", strconv.Itoa(v.Size))
func (s SymlinkConfig) Len() int { }
return 3 args = append(args, arg, v.Dir)
} return
default:
func (s SymlinkConfig) Append(args *[]string) { panic("unreachable")
*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,25 @@
package bwrap
const (
Tmpfs = iota
Dir
Symlink
interfaceC
)
var interfaceArgs = func() (g [interfaceC]string) {
g[Tmpfs] = "--tmpfs"
g[Dir] = "--dir"
g[Symlink] = "--symlink"
return
}()
func (c *Config) interfaceArgs() (g [interfaceC][]argOf) {
g[Tmpfs] = copyToArgOfSlice(c.Tmpfs)
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,145 +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
}
// SetSync sets the sync pipe kept open while sandbox is running
// (--sync-fd FD)
func (c *Config) SetSync(s *os.File) *Config {
c.sync = s
return c
}

View File

@ -0,0 +1,44 @@
package bwrap
const (
Hostname = iota
Chdir
UnsetEnv
LockFile
RemountRO
Procfs
DevTmpfs
Mqueue
stringC
)
var stringArgs = func() (n [stringC]string) {
n[Hostname] = "--hostname"
n[Chdir] = "--chdir"
n[UnsetEnv] = "--unsetenv"
n[LockFile] = "--lock-file"
n[RemountRO] = "--remount-ro"
n[Procfs] = "--proc"
n[DevTmpfs] = "--dev"
n[Mqueue] = "--mqueue"
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
n[Procfs] = c.Procfs
n[DevTmpfs] = c.DevTmpfs
n[Mqueue] = c.Mqueue
return
}

View File

@ -13,38 +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",
"--assert-userns-disabled",
"--clearenv",
"--die-with-parent",
"--symlink", "usr/bin", "/bin", "--symlink", "usr/bin", "/bin",
"--symlink", "var/home", "/home", "--symlink", "var/home", "/home",
"--symlink", "usr/lib", "/lib", "--symlink", "usr/lib", "/lib",
@ -69,148 +78,6 @@ func TestConfig_Args(t *testing.T) {
"--ro-bind", "/etc", "/etc", "--ro-bind", "/etc", "/etc",
}, },
}, },
{
name: "fortify permissive default nixos",
conf: (&Config{
Unshare: nil,
Net: true,
UserNS: true,
Clearenv: true,
SetEnv: map[string]string{
"HOME": "/home/chronos",
"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",
},
},
} }
for _, tc := range testCases { for _, tc := range testCases {

View File

@ -7,8 +7,8 @@ import (
"strings" "strings"
"testing" "testing"
"git.gensokyo.uk/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
"git.gensokyo.uk/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.gensokyo.uk/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
) )
func TestDirect(t *testing.T) { func TestDirect(t *testing.T) {

View File

@ -1,4 +1,6 @@
// Package helper runs external helpers with optional sandboxing and manages their status/args pipes. /*
Package helper runs external helpers and manages their status and args FDs.
*/
package helper package helper
import ( import (

View File

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

View File

@ -5,8 +5,6 @@ import (
"io" "io"
"os" "os"
"os/exec" "os/exec"
"git.gensokyo.uk/security/fortify/internal/proc"
) )
type pipes struct { type pipes struct {
@ -49,21 +47,24 @@ func (p *pipes) pipe() error {
} }
// calls pipe to create pipes and sets them up as ExtraFiles, returning their fd // calls pipe to create pipes and sets them up as ExtraFiles, returning their fd
func (p *pipes) prepareCmd(cmd *exec.Cmd) (argsFd, statFd int, err error) { func (p *pipes) prepareCmd(cmd *exec.Cmd) (int, int, error) {
argsFd, statFd = -1, -1 if err := p.pipe(); err != nil {
if err = p.pipe(); err != nil { return -1, -1, err
return
} }
// save a reference of cmd for future use // save a reference of cmd for future use
p.cmd = cmd p.cmd = cmd
argsFd = int(proc.ExtraFile(cmd, p.argsP[0])) // ExtraFiles: If non-nil, entry i becomes file descriptor 3+i.
if p.ready != nil { argsFd := 3 + len(cmd.ExtraFiles)
statFd = int(proc.ExtraFile(cmd, p.statP[1])) cmd.ExtraFiles = append(cmd.ExtraFiles, p.argsP[0])
}
return if p.ready != nil {
cmd.ExtraFiles = append(cmd.ExtraFiles, p.statP[1])
return argsFd, argsFd + 1, nil
} else {
return argsFd, -1, nil
}
} }
func (p *pipes) readyWriteArgs() error { func (p *pipes) readyWriteArgs() error {

View File

@ -10,8 +10,7 @@ import (
"syscall" "syscall"
"testing" "testing"
"git.gensokyo.uk/security/fortify/helper/bwrap" "git.ophivana.moe/cat/fortify/helper/bwrap"
"git.gensokyo.uk/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.gensokyo.uk/security/fortify/helper" "git.ophivana.moe/cat/fortify/helper"
) )
func TestHelperChildStub(t *testing.T) { func TestHelperChildStub(t *testing.T) {

View File

@ -1,50 +1,32 @@
package app package app
import ( import (
"net"
"os/exec"
"sync" "sync"
"sync/atomic"
"git.gensokyo.uk/security/fortify/cmd/fshim/ipc/shim"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/linux"
) )
type App interface { type App interface {
// ID returns a copy of App's unique ID. Seal(config *Config) error
ID() fst.ID
// Start sets up the system and starts the App.
Start() error Start() error
// Wait waits for App's process to exit and reverts system setup.
Wait() (int, error) Wait() (int, error)
// WaitErr returns error returned by the underlying wait syscall.
WaitErr() error WaitErr() error
Seal(config *fst.Config) error
String() string String() string
} }
type app struct { type app struct {
// single-use config reference
ct *appCt
// application unique identifier
id *fst.ID
// operating system interface
os linux.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
// wayland connection if wayland mediation is enabled
wayland *net.UnixConn
// error returned waiting for process // error returned waiting for process
waitErr error wait error
lock sync.RWMutex lock sync.RWMutex
} }
func (a *app) ID() fst.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)"
@ -53,45 +35,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.us + ")" 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 linux.System) (App, error) { func New() App {
a := new(app) return new(app)
a.id = new(fst.ID)
a.os = os
return a, fst.NewAppID(a.id)
}
// appCt ensures its wrapped val is only accessed once
type appCt struct {
val *fst.Config
done *atomic.Bool
}
func (a *appCt) Unwrap() *fst.Config {
if !a.done.Load() {
defer a.done.Store(true)
return a.val
}
panic("attempted to access config reference twice")
}
func newAppCt(config *fst.Config) (ct *appCt) {
ct = new(appCt)
ct.done = new(atomic.Bool)
ct.val = config
return ct
} }

View File

@ -1,225 +0,0 @@
package app_test
import (
"git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/system"
)
var testCasesNixos = []sealTestCase{
{
"nixos chromium direct wayland", new(stubNixOS),
&fst.Config{
ID: "org.chromium.Chromium",
Command: []string{"/nix/store/yqivzpzzn7z5x0lq9hmbzygh45d8rhqd-chromium-start"},
Confinement: fst.ConfinementConfig{
AppID: 1, Groups: []string{}, Username: "u0_a1",
Outer: "/var/lib/persist/module/fortify/0/1",
Sandbox: &fst.SandboxConfig{
UserNS: true, Net: true, MapRealUID: true, DirectWayland: true, Env: nil,
Filesystem: []*fst.FilesystemConfig{
{Src: "/bin", Must: true}, {Src: "/usr/bin", Must: true},
{Src: "/nix/store", Must: true}, {Src: "/run/current-system", Must: true},
{Src: "/sys/block"}, {Src: "/sys/bus"}, {Src: "/sys/class"}, {Src: "/sys/dev"}, {Src: "/sys/devices"},
{Src: "/run/opengl-driver", Must: true}, {Src: "/dev/dri", Device: true},
}, AutoEtc: true,
Override: []string{"/var/run/nscd"},
},
SystemBus: &dbus.Config{
Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"},
Filter: true,
},
SessionBus: &dbus.Config{
Talk: []string{
"org.freedesktop.FileManager1", "org.freedesktop.Notifications",
"org.freedesktop.ScreenSaver", "org.freedesktop.secrets",
"org.kde.kwalletd5", "org.kde.kwalletd6",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{}, Broadcast: map[string]string{},
Filter: true,
},
Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(),
},
},
fst.ID{
0x8e, 0x2c, 0x76, 0xb0,
0x66, 0xda, 0xbe, 0x57,
0x4c, 0xf0, 0x73, 0xbd,
0xb4, 0x6e, 0xb5, 0xc1,
},
system.New(1000001).
Ensure("/tmp/fortify.1971", 0711).
Ephemeral(system.Process, "/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1", 0711).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/1", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/1", 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/8e2c76b066dabe574cf073bdb46eb5c1", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1", acl.Execute).
WriteType(system.Process, "/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/passwd", "u0_a1:x:1971:1971:Fortify:/var/lib/persist/module/fortify/0/1:/run/current-system/sw/bin/zsh\n").
WriteType(system.Process, "/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/group", "fortify:x:1971:\n").
Link("/run/user/1971/wayland-0", "/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1/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/8e2c76b066dabe574cf073bdb46eb5c1/pulse").
CopyFile("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/pulse-cookie", "/home/ophestra/xdg/config/pulse/cookie").
MustProxyDBus("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", &dbus.Config{
Talk: []string{
"org.freedesktop.FileManager1", "org.freedesktop.Notifications",
"org.freedesktop.ScreenSaver", "org.freedesktop.secrets",
"org.kde.kwalletd5", "org.kde.kwalletd6",
},
Own: []string{
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*",
},
Call: map[string]string{}, Broadcast: map[string]string{},
Filter: true,
}, "/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", &dbus.Config{
Talk: []string{
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower",
},
Filter: true,
}).
UpdatePerm("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", acl.Read, acl.Write).
UpdatePerm("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", acl.Read, acl.Write),
(&bwrap.Config{
Net: true,
UserNS: true,
Chdir: "/var/lib/persist/module/fortify/0/1",
Clearenv: true,
SetEnv: map[string]string{
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1971/bus",
"DBUS_SYSTEM_BUS_ADDRESS": "unix:path=/run/dbus/system_bus_socket",
"HOME": "/var/lib/persist/module/fortify/0/1",
"PULSE_COOKIE": "/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/pulse-cookie",
"PULSE_SERVER": "unix:/run/user/1971/pulse/native",
"SHELL": "/run/current-system/sw/bin/zsh",
"TERM": "xterm-256color",
"USER": "u0_a1",
"WAYLAND_DISPLAY": "/run/user/1971/wayland-0",
"XDG_RUNTIME_DIR": "/run/user/1971",
"XDG_SESSION_CLASS": "user",
"XDG_SESSION_TYPE": "tty",
},
Chmod: make(bwrap.ChmodConfig),
NewSession: true,
DieWithParent: true,
AsInit: true,
}).SetUID(1971).SetGID(1971).
Procfs("/proc").
Tmpfs("/fortify", 4096).
DevTmpfs("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin").
Bind("/usr/bin", "/usr/bin").
Bind("/nix/store", "/nix/store").
Bind("/run/current-system", "/run/current-system").
Bind("/sys/block", "/sys/block", true).
Bind("/sys/bus", "/sys/bus", true).
Bind("/sys/class", "/sys/class", true).
Bind("/sys/dev", "/sys/dev", true).
Bind("/sys/devices", "/sys/devices", true).
Bind("/run/opengl-driver", "/run/opengl-driver").
Bind("/dev/dri", "/dev/dri", true, true, true).
Bind("/etc", "/fortify/etc").
Symlink("/fortify/etc/alsa", "/etc/alsa").
Symlink("/fortify/etc/bashrc", "/etc/bashrc").
Symlink("/fortify/etc/binfmt.d", "/etc/binfmt.d").
Symlink("/fortify/etc/dbus-1", "/etc/dbus-1").
Symlink("/fortify/etc/default", "/etc/default").
Symlink("/fortify/etc/ethertypes", "/etc/ethertypes").
Symlink("/fortify/etc/fonts", "/etc/fonts").
Symlink("/fortify/etc/fstab", "/etc/fstab").
Symlink("/fortify/etc/fuse.conf", "/etc/fuse.conf").
Symlink("/fortify/etc/host.conf", "/etc/host.conf").
Symlink("/fortify/etc/hostid", "/etc/hostid").
Symlink("/fortify/etc/hostname", "/etc/hostname").
Symlink("/fortify/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Symlink("/fortify/etc/hosts", "/etc/hosts").
Symlink("/fortify/etc/inputrc", "/etc/inputrc").
Symlink("/fortify/etc/ipsec.d", "/etc/ipsec.d").
Symlink("/fortify/etc/issue", "/etc/issue").
Symlink("/fortify/etc/kbd", "/etc/kbd").
Symlink("/fortify/etc/libblockdev", "/etc/libblockdev").
Symlink("/fortify/etc/locale.conf", "/etc/locale.conf").
Symlink("/fortify/etc/localtime", "/etc/localtime").
Symlink("/fortify/etc/login.defs", "/etc/login.defs").
Symlink("/fortify/etc/lsb-release", "/etc/lsb-release").
Symlink("/fortify/etc/lvm", "/etc/lvm").
Symlink("/fortify/etc/machine-id", "/etc/machine-id").
Symlink("/fortify/etc/man_db.conf", "/etc/man_db.conf").
Symlink("/fortify/etc/modprobe.d", "/etc/modprobe.d").
Symlink("/fortify/etc/modules-load.d", "/etc/modules-load.d").
Symlink("/proc/mounts", "/etc/mtab").
Symlink("/fortify/etc/nanorc", "/etc/nanorc").
Symlink("/fortify/etc/netgroup", "/etc/netgroup").
Symlink("/fortify/etc/NetworkManager", "/etc/NetworkManager").
Symlink("/fortify/etc/nix", "/etc/nix").
Symlink("/fortify/etc/nixos", "/etc/nixos").
Symlink("/fortify/etc/NIXOS", "/etc/NIXOS").
Symlink("/fortify/etc/nscd.conf", "/etc/nscd.conf").
Symlink("/fortify/etc/nsswitch.conf", "/etc/nsswitch.conf").
Symlink("/fortify/etc/opensnitchd", "/etc/opensnitchd").
Symlink("/fortify/etc/os-release", "/etc/os-release").
Symlink("/fortify/etc/pam", "/etc/pam").
Symlink("/fortify/etc/pam.d", "/etc/pam.d").
Symlink("/fortify/etc/pipewire", "/etc/pipewire").
Symlink("/fortify/etc/pki", "/etc/pki").
Symlink("/fortify/etc/polkit-1", "/etc/polkit-1").
Symlink("/fortify/etc/profile", "/etc/profile").
Symlink("/fortify/etc/protocols", "/etc/protocols").
Symlink("/fortify/etc/qemu", "/etc/qemu").
Symlink("/fortify/etc/resolv.conf", "/etc/resolv.conf").
Symlink("/fortify/etc/resolvconf.conf", "/etc/resolvconf.conf").
Symlink("/fortify/etc/rpc", "/etc/rpc").
Symlink("/fortify/etc/samba", "/etc/samba").
Symlink("/fortify/etc/sddm.conf", "/etc/sddm.conf").
Symlink("/fortify/etc/secureboot", "/etc/secureboot").
Symlink("/fortify/etc/services", "/etc/services").
Symlink("/fortify/etc/set-environment", "/etc/set-environment").
Symlink("/fortify/etc/shadow", "/etc/shadow").
Symlink("/fortify/etc/shells", "/etc/shells").
Symlink("/fortify/etc/ssh", "/etc/ssh").
Symlink("/fortify/etc/ssl", "/etc/ssl").
Symlink("/fortify/etc/static", "/etc/static").
Symlink("/fortify/etc/subgid", "/etc/subgid").
Symlink("/fortify/etc/subuid", "/etc/subuid").
Symlink("/fortify/etc/sudoers", "/etc/sudoers").
Symlink("/fortify/etc/sysctl.d", "/etc/sysctl.d").
Symlink("/fortify/etc/systemd", "/etc/systemd").
Symlink("/fortify/etc/terminfo", "/etc/terminfo").
Symlink("/fortify/etc/tmpfiles.d", "/etc/tmpfiles.d").
Symlink("/fortify/etc/udev", "/etc/udev").
Symlink("/fortify/etc/udisks2", "/etc/udisks2").
Symlink("/fortify/etc/UPower", "/etc/UPower").
Symlink("/fortify/etc/vconsole.conf", "/etc/vconsole.conf").
Symlink("/fortify/etc/X11", "/etc/X11").
Symlink("/fortify/etc/zfs", "/etc/zfs").
Symlink("/fortify/etc/zinputrc", "/etc/zinputrc").
Symlink("/fortify/etc/zoneinfo", "/etc/zoneinfo").
Symlink("/fortify/etc/zprofile", "/etc/zprofile").
Symlink("/fortify/etc/zshenv", "/etc/zshenv").
Symlink("/fortify/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/1", "/tmp", false, true).
Tmpfs("/tmp/fortify.1971", 1048576).
Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/1971", 8388608).
Bind("/var/lib/persist/module/fortify/0/1", "/var/lib/persist/module/fortify/0/1", false, true).
Bind("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/passwd", "/etc/passwd").
Bind("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/group", "/etc/group").
Bind("/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1/wayland", "/run/user/1971/wayland-0").
Bind("/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1/pulse", "/run/user/1971/pulse/native").
Bind("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/pulse-cookie", "/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/pulse-cookie").
Bind("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", "/run/user/1971/bus").
Bind("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/system_bus_socket", "/run/dbus/system_bus_socket").
Tmpfs("/var/run/nscd", 8192),
},
}

View File

@ -1,452 +0,0 @@
package app_test
import (
"git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/system"
)
var testCasesPd = []sealTestCase{
{
"nixos permissive defaults no enablements", new(stubNixOS),
&fst.Config{
Command: make([]string, 0),
Confinement: fst.ConfinementConfig{
AppID: 0,
Username: "chronos",
Outer: "/home/chronos",
},
},
fst.ID{
0x4a, 0x45, 0x0b, 0x65,
0x96, 0xd7, 0xbc, 0x15,
0xbd, 0x01, 0x78, 0x0e,
0xb9, 0xa6, 0x07, 0xac,
},
system.New(1000000).
Ensure("/tmp/fortify.1971", 0711).
Ephemeral(system.Process, "/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac", 0711).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/0", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/0", acl.Read, acl.Write, acl.Execute).
Ensure("/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,
Chdir: "/home/chronos",
SetEnv: map[string]string{
"HOME": "/home/chronos",
"SHELL": "/run/current-system/sw/bin/zsh",
"TERM": "xterm-256color",
"USER": "chronos",
"XDG_RUNTIME_DIR": "/run/user/65534",
"XDG_SESSION_CLASS": "user",
"XDG_SESSION_TYPE": "tty"},
Chmod: make(bwrap.ChmodConfig),
DieWithParent: true,
AsInit: true,
}).SetUID(65534).SetGID(65534).
Procfs("/proc").
Tmpfs("/fortify", 4096).
DevTmpfs("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", false, true).
Bind("/boot", "/boot", false, true).
Bind("/home", "/home", false, true).
Bind("/lib", "/lib", false, true).
Bind("/lib64", "/lib64", false, true).
Bind("/nix", "/nix", false, true).
Bind("/root", "/root", false, true).
Bind("/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("/etc", "/fortify/etc").
Symlink("/fortify/etc/alsa", "/etc/alsa").
Symlink("/fortify/etc/bashrc", "/etc/bashrc").
Symlink("/fortify/etc/binfmt.d", "/etc/binfmt.d").
Symlink("/fortify/etc/dbus-1", "/etc/dbus-1").
Symlink("/fortify/etc/default", "/etc/default").
Symlink("/fortify/etc/ethertypes", "/etc/ethertypes").
Symlink("/fortify/etc/fonts", "/etc/fonts").
Symlink("/fortify/etc/fstab", "/etc/fstab").
Symlink("/fortify/etc/fuse.conf", "/etc/fuse.conf").
Symlink("/fortify/etc/host.conf", "/etc/host.conf").
Symlink("/fortify/etc/hostid", "/etc/hostid").
Symlink("/fortify/etc/hostname", "/etc/hostname").
Symlink("/fortify/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Symlink("/fortify/etc/hosts", "/etc/hosts").
Symlink("/fortify/etc/inputrc", "/etc/inputrc").
Symlink("/fortify/etc/ipsec.d", "/etc/ipsec.d").
Symlink("/fortify/etc/issue", "/etc/issue").
Symlink("/fortify/etc/kbd", "/etc/kbd").
Symlink("/fortify/etc/libblockdev", "/etc/libblockdev").
Symlink("/fortify/etc/locale.conf", "/etc/locale.conf").
Symlink("/fortify/etc/localtime", "/etc/localtime").
Symlink("/fortify/etc/login.defs", "/etc/login.defs").
Symlink("/fortify/etc/lsb-release", "/etc/lsb-release").
Symlink("/fortify/etc/lvm", "/etc/lvm").
Symlink("/fortify/etc/machine-id", "/etc/machine-id").
Symlink("/fortify/etc/man_db.conf", "/etc/man_db.conf").
Symlink("/fortify/etc/modprobe.d", "/etc/modprobe.d").
Symlink("/fortify/etc/modules-load.d", "/etc/modules-load.d").
Symlink("/proc/mounts", "/etc/mtab").
Symlink("/fortify/etc/nanorc", "/etc/nanorc").
Symlink("/fortify/etc/netgroup", "/etc/netgroup").
Symlink("/fortify/etc/NetworkManager", "/etc/NetworkManager").
Symlink("/fortify/etc/nix", "/etc/nix").
Symlink("/fortify/etc/nixos", "/etc/nixos").
Symlink("/fortify/etc/NIXOS", "/etc/NIXOS").
Symlink("/fortify/etc/nscd.conf", "/etc/nscd.conf").
Symlink("/fortify/etc/nsswitch.conf", "/etc/nsswitch.conf").
Symlink("/fortify/etc/opensnitchd", "/etc/opensnitchd").
Symlink("/fortify/etc/os-release", "/etc/os-release").
Symlink("/fortify/etc/pam", "/etc/pam").
Symlink("/fortify/etc/pam.d", "/etc/pam.d").
Symlink("/fortify/etc/pipewire", "/etc/pipewire").
Symlink("/fortify/etc/pki", "/etc/pki").
Symlink("/fortify/etc/polkit-1", "/etc/polkit-1").
Symlink("/fortify/etc/profile", "/etc/profile").
Symlink("/fortify/etc/protocols", "/etc/protocols").
Symlink("/fortify/etc/qemu", "/etc/qemu").
Symlink("/fortify/etc/resolv.conf", "/etc/resolv.conf").
Symlink("/fortify/etc/resolvconf.conf", "/etc/resolvconf.conf").
Symlink("/fortify/etc/rpc", "/etc/rpc").
Symlink("/fortify/etc/samba", "/etc/samba").
Symlink("/fortify/etc/sddm.conf", "/etc/sddm.conf").
Symlink("/fortify/etc/secureboot", "/etc/secureboot").
Symlink("/fortify/etc/services", "/etc/services").
Symlink("/fortify/etc/set-environment", "/etc/set-environment").
Symlink("/fortify/etc/shadow", "/etc/shadow").
Symlink("/fortify/etc/shells", "/etc/shells").
Symlink("/fortify/etc/ssh", "/etc/ssh").
Symlink("/fortify/etc/ssl", "/etc/ssl").
Symlink("/fortify/etc/static", "/etc/static").
Symlink("/fortify/etc/subgid", "/etc/subgid").
Symlink("/fortify/etc/subuid", "/etc/subuid").
Symlink("/fortify/etc/sudoers", "/etc/sudoers").
Symlink("/fortify/etc/sysctl.d", "/etc/sysctl.d").
Symlink("/fortify/etc/systemd", "/etc/systemd").
Symlink("/fortify/etc/terminfo", "/etc/terminfo").
Symlink("/fortify/etc/tmpfiles.d", "/etc/tmpfiles.d").
Symlink("/fortify/etc/udev", "/etc/udev").
Symlink("/fortify/etc/udisks2", "/etc/udisks2").
Symlink("/fortify/etc/UPower", "/etc/UPower").
Symlink("/fortify/etc/vconsole.conf", "/etc/vconsole.conf").
Symlink("/fortify/etc/X11", "/etc/X11").
Symlink("/fortify/etc/zfs", "/etc/zfs").
Symlink("/fortify/etc/zinputrc", "/etc/zinputrc").
Symlink("/fortify/etc/zoneinfo", "/etc/zoneinfo").
Symlink("/fortify/etc/zprofile", "/etc/zprofile").
Symlink("/fortify/etc/zshenv", "/etc/zshenv").
Symlink("/fortify/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/0", "/tmp", false, true).
Tmpfs("/tmp/fortify.1971", 1048576).
Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/65534", 8388608).
Bind("/home/chronos", "/home/chronos", false, true).
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),
&fst.Config{
ID: "org.chromium.Chromium",
Command: []string{"/run/current-system/sw/bin/zsh", "-c", "exec chromium "},
Confinement: fst.ConfinementConfig{
AppID: 9,
Groups: []string{"video"},
Username: "chronos",
Outer: "/home/chronos",
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(),
},
},
fst.ID{
0xeb, 0xf0, 0x83, 0xd1,
0xb1, 0x75, 0x91, 0x17,
0x82, 0xd4, 0x13, 0x36,
0x9b, 0x64, 0xce, 0x7c,
},
system.New(1000009).
Ensure("/tmp/fortify.1971", 0711).
Ephemeral(system.Process, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c", 0711).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/9", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/9", 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").
Ensure("/tmp/fortify.1971/wayland", 0711).
Wayland("/tmp/fortify.1971/wayland/ebf083d1b175911782d413369b64ce7c", "/run/user/1971/wayland-0", "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c").
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,
Chdir: "/home/chronos",
Clearenv: true,
SetEnv: map[string]string{
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/65534/bus",
"DBUS_SYSTEM_BUS_ADDRESS": "unix:path=/run/dbus/system_bus_socket",
"HOME": "/home/chronos",
"PULSE_COOKIE": "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/pulse-cookie",
"PULSE_SERVER": "unix:/run/user/65534/pulse/native",
"SHELL": "/run/current-system/sw/bin/zsh",
"TERM": "xterm-256color",
"USER": "chronos",
"WAYLAND_DISPLAY": "/run/user/65534/wayland-0",
"XDG_RUNTIME_DIR": "/run/user/65534",
"XDG_SESSION_CLASS": "user",
"XDG_SESSION_TYPE": "tty",
},
Chmod: make(bwrap.ChmodConfig),
DieWithParent: true,
AsInit: true,
}).SetUID(65534).SetGID(65534).
Procfs("/proc").
Tmpfs("/fortify", 4096).
DevTmpfs("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", false, true).
Bind("/boot", "/boot", false, true).
Bind("/home", "/home", false, true).
Bind("/lib", "/lib", false, true).
Bind("/lib64", "/lib64", false, true).
Bind("/nix", "/nix", false, true).
Bind("/root", "/root", false, true).
Bind("/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).
Bind("/etc", "/fortify/etc").
Symlink("/fortify/etc/alsa", "/etc/alsa").
Symlink("/fortify/etc/bashrc", "/etc/bashrc").
Symlink("/fortify/etc/binfmt.d", "/etc/binfmt.d").
Symlink("/fortify/etc/dbus-1", "/etc/dbus-1").
Symlink("/fortify/etc/default", "/etc/default").
Symlink("/fortify/etc/ethertypes", "/etc/ethertypes").
Symlink("/fortify/etc/fonts", "/etc/fonts").
Symlink("/fortify/etc/fstab", "/etc/fstab").
Symlink("/fortify/etc/fuse.conf", "/etc/fuse.conf").
Symlink("/fortify/etc/host.conf", "/etc/host.conf").
Symlink("/fortify/etc/hostid", "/etc/hostid").
Symlink("/fortify/etc/hostname", "/etc/hostname").
Symlink("/fortify/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Symlink("/fortify/etc/hosts", "/etc/hosts").
Symlink("/fortify/etc/inputrc", "/etc/inputrc").
Symlink("/fortify/etc/ipsec.d", "/etc/ipsec.d").
Symlink("/fortify/etc/issue", "/etc/issue").
Symlink("/fortify/etc/kbd", "/etc/kbd").
Symlink("/fortify/etc/libblockdev", "/etc/libblockdev").
Symlink("/fortify/etc/locale.conf", "/etc/locale.conf").
Symlink("/fortify/etc/localtime", "/etc/localtime").
Symlink("/fortify/etc/login.defs", "/etc/login.defs").
Symlink("/fortify/etc/lsb-release", "/etc/lsb-release").
Symlink("/fortify/etc/lvm", "/etc/lvm").
Symlink("/fortify/etc/machine-id", "/etc/machine-id").
Symlink("/fortify/etc/man_db.conf", "/etc/man_db.conf").
Symlink("/fortify/etc/modprobe.d", "/etc/modprobe.d").
Symlink("/fortify/etc/modules-load.d", "/etc/modules-load.d").
Symlink("/proc/mounts", "/etc/mtab").
Symlink("/fortify/etc/nanorc", "/etc/nanorc").
Symlink("/fortify/etc/netgroup", "/etc/netgroup").
Symlink("/fortify/etc/NetworkManager", "/etc/NetworkManager").
Symlink("/fortify/etc/nix", "/etc/nix").
Symlink("/fortify/etc/nixos", "/etc/nixos").
Symlink("/fortify/etc/NIXOS", "/etc/NIXOS").
Symlink("/fortify/etc/nscd.conf", "/etc/nscd.conf").
Symlink("/fortify/etc/nsswitch.conf", "/etc/nsswitch.conf").
Symlink("/fortify/etc/opensnitchd", "/etc/opensnitchd").
Symlink("/fortify/etc/os-release", "/etc/os-release").
Symlink("/fortify/etc/pam", "/etc/pam").
Symlink("/fortify/etc/pam.d", "/etc/pam.d").
Symlink("/fortify/etc/pipewire", "/etc/pipewire").
Symlink("/fortify/etc/pki", "/etc/pki").
Symlink("/fortify/etc/polkit-1", "/etc/polkit-1").
Symlink("/fortify/etc/profile", "/etc/profile").
Symlink("/fortify/etc/protocols", "/etc/protocols").
Symlink("/fortify/etc/qemu", "/etc/qemu").
Symlink("/fortify/etc/resolv.conf", "/etc/resolv.conf").
Symlink("/fortify/etc/resolvconf.conf", "/etc/resolvconf.conf").
Symlink("/fortify/etc/rpc", "/etc/rpc").
Symlink("/fortify/etc/samba", "/etc/samba").
Symlink("/fortify/etc/sddm.conf", "/etc/sddm.conf").
Symlink("/fortify/etc/secureboot", "/etc/secureboot").
Symlink("/fortify/etc/services", "/etc/services").
Symlink("/fortify/etc/set-environment", "/etc/set-environment").
Symlink("/fortify/etc/shadow", "/etc/shadow").
Symlink("/fortify/etc/shells", "/etc/shells").
Symlink("/fortify/etc/ssh", "/etc/ssh").
Symlink("/fortify/etc/ssl", "/etc/ssl").
Symlink("/fortify/etc/static", "/etc/static").
Symlink("/fortify/etc/subgid", "/etc/subgid").
Symlink("/fortify/etc/subuid", "/etc/subuid").
Symlink("/fortify/etc/sudoers", "/etc/sudoers").
Symlink("/fortify/etc/sysctl.d", "/etc/sysctl.d").
Symlink("/fortify/etc/systemd", "/etc/systemd").
Symlink("/fortify/etc/terminfo", "/etc/terminfo").
Symlink("/fortify/etc/tmpfiles.d", "/etc/tmpfiles.d").
Symlink("/fortify/etc/udev", "/etc/udev").
Symlink("/fortify/etc/udisks2", "/etc/udisks2").
Symlink("/fortify/etc/UPower", "/etc/UPower").
Symlink("/fortify/etc/vconsole.conf", "/etc/vconsole.conf").
Symlink("/fortify/etc/X11", "/etc/X11").
Symlink("/fortify/etc/zfs", "/etc/zfs").
Symlink("/fortify/etc/zinputrc", "/etc/zinputrc").
Symlink("/fortify/etc/zoneinfo", "/etc/zoneinfo").
Symlink("/fortify/etc/zprofile", "/etc/zprofile").
Symlink("/fortify/etc/zshenv", "/etc/zshenv").
Symlink("/fortify/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/9", "/tmp", false, true).
Tmpfs("/tmp/fortify.1971", 1048576).
Tmpfs("/run/user", 1048576).
Tmpfs("/run/user/65534", 8388608).
Bind("/home/chronos", "/home/chronos", false, true).
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/passwd", "/etc/passwd").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/group", "/etc/group").
Bind("/tmp/fortify.1971/wayland/ebf083d1b175911782d413369b64ce7c", "/run/user/65534/wayland-0").
Bind("/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/pulse", "/run/user/65534/pulse/native").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/pulse-cookie", "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/pulse-cookie").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", "/run/user/65534/bus").
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/system_bus_socket", "/run/dbus/system_bus_socket").
Tmpfs("/var/run/nscd", 8192),
},
}

View File

@ -1,153 +0,0 @@
package app_test
import (
"fmt"
"io"
"io/fs"
"os/user"
"strconv"
"git.gensokyo.uk/security/fortify/internal/linux"
)
// 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) LookupGroup(name string) (*user.Group, error) {
switch name {
case "video":
return &user.Group{Gid: "26", Name: "video"}, nil
default:
return nil, user.UnknownGroupError(name)
}
}
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) Stdout() io.Writer {
panic("requested stdout")
}
func (s *stubNixOS) Paths() linux.Paths {
return linux.Paths{
SharePath: "/tmp/fortify.1971",
RuntimePath: "/run/user/1971",
RunDirPath: "/run/user/1971/fortify",
}
}
func (s *stubNixOS) Uid(aid int) (int, error) {
return 1000000 + 0*10000 + aid, nil
}
func (s *stubNixOS) SdBooted() bool {
return true
}

View File

@ -1,135 +0,0 @@
package app_test
import (
"io/fs"
"reflect"
"testing"
"time"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/app"
"git.gensokyo.uk/security/fortify/internal/linux"
"git.gensokyo.uk/security/fortify/internal/system"
)
type sealTestCase struct {
name string
os linux.System
config *fst.Config
id fst.ID
wantSys *system.I
wantBwrap *bwrap.Config
}
func TestApp(t *testing.T) {
testCases := append(testCasesPd, 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,22 +1,21 @@
package fst package app
import ( import (
"errors" "git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/cat/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/dbus" "git.ophivana.moe/cat/fortify/internal/state"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/linux"
"git.gensokyo.uk/security/fortify/internal/system"
) )
const fTmp = "/fortify"
// Config is used to seal an *App // Config is used to seal an *App
type Config struct { type Config struct {
// D-Bus application ID // D-Bus application ID
ID string `json:"id"` ID string `json:"id"`
// username of the target user to switch to
User string `json:"user"`
// value passed through to the child process as its argv // value passed through to the child process as its argv
Command []string `json:"command"` Command []string `json:"command"`
// string representation of the child's launch method
Method string `json:"method"`
// child confinement configuration // child confinement configuration
Confinement ConfinementConfig `json:"confinement"` Confinement ConfinementConfig `json:"confinement"`
@ -24,16 +23,6 @@ type Config struct {
// ConfinementConfig defines fortified child's confinement // ConfinementConfig defines fortified child's confinement
type ConfinementConfig struct { type ConfinementConfig struct {
// numerical application id, determines uid in the init namespace
AppID int `json:"app_id"`
// list of supplementary groups to inherit
Groups []string `json:"groups"`
// passwd username in the sandbox, defaults to chronos
Username string `json:"username,omitempty"`
// home directory in sandbox, empty for outer
Inner string `json:"home_inner"`
// home directory in init namespace
Outer string `json:"home"`
// bwrap sandbox confinement configuration // bwrap sandbox confinement configuration
Sandbox *SandboxConfig `json:"sandbox"` Sandbox *SandboxConfig `json:"sandbox"`
@ -45,7 +34,7 @@ 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. // SandboxConfig describes resources made available to the sandbox.
@ -56,25 +45,17 @@ type SandboxConfig struct {
UserNS bool `json:"userns,omitempty"` UserNS bool `json:"userns,omitempty"`
// share net namespace // share net namespace
Net bool `json:"net,omitempty"` Net bool `json:"net,omitempty"`
// share all devices
Dev bool `json:"dev,omitempty"`
// do not run in new session // do not run in new session
NoNewSession bool `json:"no_new_session,omitempty"` NoNewSession bool `json:"no_new_session,omitempty"`
// map target user uid to privileged user uid in the user namespace // mediated access to wayland socket
MapRealUID bool `json:"map_real_uid"` Wayland bool `json:"wayland,omitempty"`
// direct access to wayland socket
DirectWayland bool `json:"direct_wayland,omitempty"`
// final environment variables // final environment variables
Env map[string]string `json:"env"` Env map[string]string `json:"env"`
// sandbox host filesystem access // sandbox host filesystem access
Filesystem []*FilesystemConfig `json:"filesystem"` Filesystem []*FilesystemConfig `json:"filesystem"`
// symlinks created inside the sandbox // tmpfs mount points to mount last
Link [][2]string `json:"symlink"` Tmpfs []bwrap.TmpfsConfig `json:"tmpfs"`
// automatically set up /etc symlinks
AutoEtc bool `json:"auto_etc"`
// paths to override by mounting tmpfs over them
Override []string `json:"override"`
} }
type FilesystemConfig struct { type FilesystemConfig struct {
@ -90,92 +71,71 @@ type FilesystemConfig struct {
Must bool `json:"require,omitempty"` Must bool `json:"require,omitempty"`
} }
// Bwrap returns the address of the corresponding bwrap.Config to s. func (s *SandboxConfig) Bwrap() *bwrap.Config {
// Note that remaining tmpfs entries must be queued by the caller prior to launch.
func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
if s == nil { if s == nil {
return nil, errors.New("nil sandbox config") return nil
} }
var uid int nobody := 65534
if !s.MapRealUID { conf := &bwrap.Config{
uid = 65534
} else {
uid = os.Geteuid()
}
conf := (&bwrap.Config{
Net: s.Net, Net: s.Net,
UserNS: s.UserNS, UserNS: s.UserNS,
UID: &nobody,
GID: &nobody,
Hostname: s.Hostname, Hostname: s.Hostname,
Clearenv: true, Clearenv: true,
SetEnv: s.Env, SetEnv: s.Env,
Procfs: []string{"/proc"},
DevTmpfs: []string{"/dev"},
Mqueue: []string{"/dev/mqueue"},
NewSession: !s.NoNewSession, NewSession: !s.NoNewSession,
DieWithParent: true, DieWithParent: true,
AsInit: true, AsInit: true,
// initialise map
Chmod: make(bwrap.ChmodConfig),
}).
SetUID(uid).SetGID(uid).
Procfs("/proc").
Tmpfs(fTmp, 4*1024)
if !s.Dev {
conf.DevTmpfs("/dev").Mqueue("/dev/mqueue")
} else {
conf.Bind("/dev", "/dev", false, true, true)
}
if !s.AutoEtc {
conf.Dir("/etc")
} }
for _, c := range s.Filesystem { for _, c := range s.Filesystem {
if c == nil { if c == nil {
continue continue
} }
src := c.Src p := [2]string{c.Src, c.Dst}
dest := c.Dst
if c.Dst == "" { if c.Dst == "" {
dest = c.Src p[1] = c.Src
}
conf.Bind(src, dest, !c.Must, c.Write, c.Device)
} }
for _, l := range s.Link { switch {
conf.Symlink(l[0], l[1]) case c.Device:
} if c.Must {
conf.DevBind = append(conf.DevBind, p)
if s.AutoEtc {
conf.Bind("/etc", fTmp+"/etc")
// link host /etc contents to prevent passwd/group from being overwritten
if d, err := os.ReadDir("/etc"); err != nil {
return nil, err
} else { } else {
for _, ent := range d { conf.DevBindTry = append(conf.DevBindTry, p)
name := ent.Name() }
switch name { case c.Write:
case "passwd": if c.Must {
case "group": conf.Bind = append(conf.Bind, p)
} else {
case "mtab": conf.BindTry = append(conf.BindTry, p)
conf.Symlink("/proc/mounts", "/etc/"+name) }
default: default:
conf.Symlink(fTmp+"/etc/"+name, "/etc/"+name) if c.Must {
} conf.ROBind = append(conf.ROBind, p)
} else {
conf.ROBindTry = append(conf.ROBindTry, p)
} }
} }
} }
return conf, nil for _, tmpfs := range s.Tmpfs {
conf.Tmpfs = append(conf.Tmpfs, bwrap.PermConfig[bwrap.TmpfsConfig]{Path: tmpfs, Last: true})
}
return conf
} }
// Template returns a fully populated instance of Config. // Template returns a fully populated instance of Config.
func Template() *Config { func Template() *Config {
return &Config{ return &Config{
ID: "org.chromium.Chromium", ID: "org.chromium.Chromium",
User: "chronos",
Command: []string{ Command: []string{
"chromium", "chromium",
"--ignore-gpu-blocklist", "--ignore-gpu-blocklist",
@ -183,20 +143,14 @@ func Template() *Config {
"--enable-features=UseOzonePlatform", "--enable-features=UseOzonePlatform",
"--ozone-platform=wayland", "--ozone-platform=wayland",
}, },
Method: "sudo",
Confinement: ConfinementConfig{ Confinement: ConfinementConfig{
AppID: 9,
Groups: []string{"video"},
Username: "chronos",
Outer: "/var/lib/persist/home/org.chromium.Chromium",
Inner: "/var/lib/fortify",
Sandbox: &SandboxConfig{ Sandbox: &SandboxConfig{
Hostname: "localhost", Hostname: "localhost",
UserNS: true, UserNS: true,
Net: true, Net: true,
NoNewSession: true, NoNewSession: true,
MapRealUID: true, Wayland: false,
Dev: true,
DirectWayland: false,
// example API credentials pulled from Google Chrome // example API credentials pulled from Google Chrome
// DO NOT USE THESE IN A REAL BROWSER // DO NOT USE THESE IN A REAL BROWSER
Env: map[string]string{ Env: map[string]string{
@ -205,16 +159,14 @@ func Template() *Config {
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT", "GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT",
}, },
Filesystem: []*FilesystemConfig{ Filesystem: []*FilesystemConfig{
{Src: "/nix/store"}, {Src: "/nix"},
{Src: "/run/current-system"}, {Src: "/storage/emulated/0", Write: true, Must: true},
{Src: "/run/opengl-driver"}, {Src: "/data/user/0", Dst: "/data/data", Write: true, Must: true},
{Src: "/var/db/nix-channels"}, {Src: "/var/tmp", Write: true},
{Src: "/home/chronos", Write: true, Must: true}, },
{Src: "/dev/dri", Device: true}, Tmpfs: []bwrap.TmpfsConfig{
{Size: 8 * 1024, Dir: "/var/run/nscd"},
}, },
Link: [][2]string{{"/run/user/65534", "/run/user/150"}},
AutoEtc: true,
Override: []string{"/var/run/nscd"},
}, },
SystemBus: &dbus.Config{ SystemBus: &dbus.Config{
See: nil, See: nil,
@ -236,7 +188,7 @@ func Template() *Config {
Log: false, Log: false,
Filter: true, Filter: true,
}, },
Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(), Enablements: state.EnableWayland.Mask() | state.EnableDBus.Mask() | state.EnablePulse.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,20 +0,0 @@
package app
import (
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/linux"
"git.gensokyo.uk/security/fortify/internal/system"
)
func NewWithID(id fst.ID, os linux.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
}

18
internal/app/id.go Normal file
View File

@ -0,0 +1,18 @@
package app
import (
"crypto/rand"
"encoding/hex"
)
type appID [16]byte
func (a *appID) String() string {
return hex.EncodeToString(a[:])
}
func newAppID() (*appID, error) {
a := &appID{}
_, err := rand.Read(a[:])
return a, err
}

View File

@ -0,0 +1,67 @@
package app
import (
"os/exec"
"strings"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
func (a *app) commandBuilderMachineCtl(shimEnv string) (args []string) {
args = make([]string, 0, 9+len(a.seal.sys.bwrap.SetEnv))
// shell --uid=$USER
args = append(args, "shell", "--uid="+a.seal.sys.Username)
// --quiet
if !verbose.Get() {
args = append(args, "--quiet")
}
// environ
envQ := make([]string, 0, len(a.seal.sys.bwrap.SetEnv)+1)
for k, v := range a.seal.sys.bwrap.SetEnv {
envQ = append(envQ, "-E"+k+"="+v)
}
// add shim payload to environment for shim path
envQ = append(envQ, "-E"+shimEnv)
args = append(args, envQ...)
// -- .host
args = append(args, "--", ".host")
// /bin/sh -c
if sh, err := exec.LookPath("sh"); err != nil {
// hardcode /bin/sh path since it exists more often than not
args = append(args, "/bin/sh", "-c")
} else {
args = append(args, sh, "-c")
}
// build inner command expression ran as target user
innerCommand := strings.Builder{}
// apply custom environment variables to activation environment
innerCommand.WriteString("dbus-update-activation-environment --systemd")
for k := range a.seal.sys.bwrap.SetEnv {
innerCommand.WriteString(" " + k)
}
innerCommand.WriteString("; ")
// override message bus address if enabled
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] + "' ")
}
}
// launch fortify as shim
innerCommand.WriteString("exec " + a.seal.sys.executable + " shim")
// append inner command
args = append(args, innerCommand.String())
return
}

View File

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

View File

@ -2,65 +2,42 @@ package app
import ( import (
"errors" "errors"
"fmt" "os"
"io/fs" "os/exec"
"os/user"
"path" "path"
"regexp"
"strconv" "strconv"
"git.gensokyo.uk/security/fortify/dbus" "git.ophivana.moe/cat/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst" "git.ophivana.moe/cat/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.ophivana.moe/cat/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/linux" "git.ophivana.moe/cat/fortify/internal/state"
"git.gensokyo.uk/security/fortify/internal/state" "git.ophivana.moe/cat/fortify/internal/verbose"
"git.gensokyo.uk/security/fortify/internal/system" )
const (
LaunchMethodSudo uint8 = iota
LaunchMethodMachineCtl
) )
var ( var (
ErrConfig = errors.New("no configuration to seal") ErrConfig = errors.New("no configuration to seal")
ErrUser = errors.New("invalid aid") ErrUser = errors.New("unknown user")
ErrHome = errors.New("invalid home directory") ErrLaunch = errors.New("invalid launch method")
ErrName = errors.New("invalid username")
ErrSudo = errors.New("sudo not available")
ErrSystemd = errors.New("systemd not available")
ErrMachineCtl = errors.New("machinectl not available")
) )
var posixUsername = regexp.MustCompilePOSIX("^[a-z_]([A-Za-z0-9_-]{0,31}|[A-Za-z0-9_-]{0,30}\\$)$") type (
SealConfigError BaseError
// appSeal seals the application with child-related information LauncherLookupError BaseError
type appSeal struct { SecurityError BaseError
// app unique ID string representation )
id string
// dbus proxy message buffer retriever
dbusMsg func(f func(msgbuf []string))
// freedesktop application ID
fid string
// argv to start process with in the final confined environment
command []string
// persistent process state store
store state.Store
// process-specific share directory path
share string
// process-specific share directory path local to XDG_RUNTIME_DIR
shareLocal string
// pass-through enablement tracking from config
et system.Enablements
// wayland socket direct access
directWayland bool
// prevents sharing from happening twice
shared bool
// seal system-level component
sys *appSealSys
linux.Paths
// protected by upstream mutex
}
// Seal seals the app launch context // Seal seals the app launch context
func (a *app) Seal(config *fst.Config) error { func (a *app) Seal(config *Config) error {
a.lock.Lock() a.lock.Lock()
defer a.lock.Unlock() defer a.lock.Unlock()
@ -69,117 +46,108 @@ func (a *app) Seal(config *fst.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
switch config.Method {
case "sudo":
seal.launchOption = LaunchMethodSudo
if sudoPath, err := exec.LookPath("sudo"); err != nil {
return (*LauncherLookupError)(wrapError(ErrSudo, "sudo not found"))
} else {
seal.toolPath = sudoPath
}
case "systemd":
seal.launchOption = LaunchMethodMachineCtl
if !internal.SdBootedV {
return (*LauncherLookupError)(wrapError(ErrSystemd,
"system has not been booted with systemd as init system"))
}
if machineCtlPath, err := exec.LookPath("machinectl"); err != nil {
return (*LauncherLookupError)(wrapError(ErrMachineCtl, "machinectl not found"))
} else {
seal.toolPath = machineCtlPath
}
default:
return (*SealConfigError)(wrapError(ErrLaunch, "invalid launch method"))
}
// create seal system component // create seal system component
seal.sys = new(appSealSys) seal.sys = new(appSealTx)
// mapped uid // look up fortify executable path
if config.Confinement.Sandbox != nil && config.Confinement.Sandbox.MapRealUID { if p, err := os.Executable(); err != nil {
seal.sys.mappedID = a.os.Geteuid() return (*LauncherLookupError)(wrapError(err, "cannot look up fortify executable path:", err))
} else { } else {
seal.sys.mappedID = 65534 seal.sys.executable = p
}
seal.sys.mappedIDString = strconv.Itoa(seal.sys.mappedID)
seal.sys.runtime = path.Join("/run/user", seal.sys.mappedIDString)
// validate uid and set user info
if config.Confinement.AppID < 0 || config.Confinement.AppID > 9999 {
return fmsg.WrapError(ErrUser,
fmt.Sprintf("aid %d out of range", config.Confinement.AppID))
} else {
seal.sys.user = appUser{
aid: config.Confinement.AppID,
as: strconv.Itoa(config.Confinement.AppID),
data: config.Confinement.Outer,
home: config.Confinement.Inner,
username: config.Confinement.Username,
}
if seal.sys.user.username == "" {
seal.sys.user.username = "chronos"
} else if !posixUsername.MatchString(seal.sys.user.username) {
return fmsg.WrapError(ErrName,
fmt.Sprintf("invalid user name %q", seal.sys.user.username))
}
if seal.sys.user.data == "" || !path.IsAbs(seal.sys.user.data) {
return fmsg.WrapError(ErrHome,
fmt.Sprintf("invalid home directory %q", seal.sys.user.data))
}
if seal.sys.user.home == "" {
seal.sys.user.home = seal.sys.user.data
} }
// invoke fsu for full uid // look up user from system
if u, err := a.os.Uid(seal.sys.user.aid); err != nil { if u, err := user.Lookup(config.User); err != nil {
return fmsg.WrapErrorSuffix(err, if errors.As(err, new(user.UnknownUserError)) {
"cannot obtain uid from fsu:") return (*SealConfigError)(wrapError(ErrUser, "unknown user", config.User))
} else { } else {
seal.sys.user.uid = u // unreachable
seal.sys.user.us = strconv.Itoa(u) panic(err)
} }
// resolve supplementary group ids from names
seal.sys.user.supp = make([]string, len(config.Confinement.Groups))
for i, name := range config.Confinement.Groups {
if g, err := a.os.LookupGroup(name); err != nil {
return fmsg.WrapError(err,
fmt.Sprintf("unknown group %q", name))
} else { } else {
seal.sys.user.supp[i] = g.Gid seal.sys.User = u
} seal.sys.runtime = path.Join("/run/user", u.Uid)
}
} }
// map sandbox config to bwrap // map sandbox config to bwrap
if config.Confinement.Sandbox == nil { if config.Confinement.Sandbox == nil {
fmsg.VPrintln("sandbox configuration not supplied, PROCEED WITH CAUTION") verbose.Println("sandbox configuration not supplied, PROCEED WITH CAUTION")
// permissive defaults // permissive defaults
conf := &fst.SandboxConfig{ conf := &SandboxConfig{
UserNS: true, UserNS: true,
Net: true, Net: true,
NoNewSession: true, NoNewSession: true,
AutoEtc: true,
} }
// bind entries in / // bind entries in /
if d, err := a.os.ReadDir("/"); err != nil { if d, err := os.ReadDir("/"); err != nil {
return err return err
} else { } else {
b := make([]*fst.FilesystemConfig, 0, len(d)) b := make([]*FilesystemConfig, 0, len(d))
for _, ent := range d { for _, ent := range d {
p := "/" + ent.Name() name := ent.Name()
switch p { switch name {
case "/proc": case "proc":
case "/dev": case "dev":
case "/run": case "run":
case "/tmp": case "mnt":
case "/mnt":
case "/etc":
default: default:
b = append(b, &fst.FilesystemConfig{Src: p, Write: true, Must: true}) p := "/" + name
b = append(b, &FilesystemConfig{Src: p, Write: true, Must: true})
} }
} }
conf.Filesystem = append(conf.Filesystem, b...) conf.Filesystem = append(conf.Filesystem, b...)
} }
// bind entries in /run // bind entries in /run
if d, err := a.os.ReadDir("/run"); err != nil { if d, err := os.ReadDir("/run"); err != nil {
return err return err
} else { } else {
b := make([]*fst.FilesystemConfig, 0, len(d)) b := make([]*FilesystemConfig, 0, len(d))
for _, ent := range d { for _, ent := range d {
name := ent.Name() name := ent.Name()
switch name { switch name {
@ -187,56 +155,62 @@ func (a *app) Seal(config *fst.Config) error {
case "dbus": case "dbus":
default: default:
p := "/run/" + name p := "/run/" + name
b = append(b, &fst.FilesystemConfig{Src: p, Write: true, Must: true}) b = append(b, &FilesystemConfig{Src: p, Write: true, Must: true})
} }
} }
conf.Filesystem = append(conf.Filesystem, b...) conf.Filesystem = append(conf.Filesystem, b...)
} }
// hide nscd from sandbox if present // hide nscd from sandbox if present
nscd := "/var/run/nscd" nscd := "/var/run/nscd"
if _, err := a.os.Stat(nscd); !errors.Is(err, fs.ErrNotExist) { if _, err := os.Stat(nscd); !errors.Is(err, os.ErrNotExist) {
conf.Override = append(conf.Override, nscd) conf.Tmpfs = append(conf.Tmpfs, bwrap.TmpfsConfig{Size: 8 * 1024, Dir: nscd})
} }
// bind GPU stuff // bind GPU stuff
if config.Confinement.Enablements.Has(system.EX11) || config.Confinement.Enablements.Has(system.EWayland) { if config.Confinement.Enablements.Has(state.EnableX) || config.Confinement.Enablements.Has(state.EnableWayland) {
conf.Filesystem = append(conf.Filesystem, &fst.FilesystemConfig{Src: "/dev/dri", Device: true}) conf.Filesystem = append(conf.Filesystem, &FilesystemConfig{Src: "/dev/dri", Device: true})
} }
config.Confinement.Sandbox = conf config.Confinement.Sandbox = conf
} }
seal.directWayland = config.Confinement.Sandbox.DirectWayland seal.sys.bwrap = config.Confinement.Sandbox.Bwrap()
if b, err := config.Confinement.Sandbox.Bwrap(a.os); err != nil {
return err
} else {
seal.sys.bwrap = b
}
seal.sys.override = config.Confinement.Sandbox.Override
if seal.sys.bwrap.SetEnv == nil { if seal.sys.bwrap.SetEnv == nil {
seal.sys.bwrap.SetEnv = make(map[string]string) seal.sys.bwrap.SetEnv = make(map[string]string)
} }
// create wayland client wait channel if mediated wayland is enabled
// this channel being set enables mediated wayland setup later on
if config.Confinement.Sandbox.Wayland {
seal.wlDone = make(chan struct{})
}
// 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.NewMulti(seal.RunDirPath) seal.store = state.NewSimple(seal.SystemConstants.RunDirPath, seal.sys.Uid)
// initialise system interface with full uid // parse string UID
seal.sys.I = system.New(seal.sys.user.uid) if u, err := strconv.Atoi(seal.sys.Uid); err != nil {
// unreachable unless kernel bug
panic("uid parse")
} else {
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.VPrintf("created application seal for uid %s (%s) groups: %v, command: %s", verbose.Println("created application seal as user",
seal.sys.user.us, seal.sys.user.username, config.Confinement.Groups, config.Command) seal.sys.Username, "("+seal.sys.Uid+"),",
"method:", config.Method+",",
"launcher:", seal.toolPath+",",
"command:", config.Command)
// seal app and release lock // seal app and release lock
a.seal = seal a.seal = seal
a.ct = newAppCt(config)
return nil return nil
} }

View File

@ -1,11 +1,15 @@
package app package app
import ( import (
"errors"
"fmt"
"os"
"path" "path"
"git.gensokyo.uk/security/fortify/acl" "git.ophivana.moe/cat/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus" "git.ophivana.moe/cat/fortify/dbus"
"git.gensokyo.uk/security/fortify/internal/system" "git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/verbose"
) )
const ( const (
@ -13,32 +17,122 @@ 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
} }
// downstream socket paths // session bus is mandatory
sessionPath, systemPath := path.Join(seal.share, "bus"), path.Join(seal.share, "system_bus_socket") if config[0] == nil {
return (*SealDBusError)(wrapError(ErrDBusConfig, "attempted to seal session bus proxy with nil config"))
// configure dbus proxy
if f, err := seal.sys.ProxyDBus(config[0], config[1], sessionPath, systemPath); err != nil {
return err
} else {
seal.dbusMsg = f
} }
// system bus is optional
seal.sys.dbusSystem = config[1] != nil
// upstream address, downstream socket path
var sessionBus, systemBus [2]string
// downstream socket paths
sessionBus[1] = path.Join(seal.share, "bus")
systemBus[1] = path.Join(seal.share, "system_bus_socket")
// resolve upstream bus addresses
sessionBus[0], systemBus[0] = dbus.Address()
// 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") sessionInner := path.Join(seal.sys.runtime, "bus")
seal.sys.bwrap.SetEnv[dbusSessionBusAddress] = "unix:path=" + sessionInner seal.sys.setEnv(dbusSessionBusAddress, "unix:path="+sessionInner)
seal.sys.bwrap.Bind(sessionPath, sessionInner) seal.sys.bind(sessionBus[1], sessionInner, true)
seal.sys.UpdatePerm(sessionPath, acl.Read, acl.Write) seal.sys.updatePerm(sessionBus[1], acl.Read, acl.Write)
if config[1] != nil { if seal.sys.dbusSystem {
systemInner := "/run/dbus/system_bus_socket" systemInner := "/run/dbus/system_bus_socket"
seal.sys.bwrap.SetEnv[dbusSystemBusAddress] = "unix:path=" + systemInner seal.sys.setEnv(dbusSystemBusAddress, "unix:path="+systemInner)
seal.sys.bwrap.Bind(systemPath, systemInner) seal.sys.bind(systemBus[1], systemInner, true)
seal.sys.UpdatePerm(systemPath, acl.Read, acl.Write) seal.sys.updatePerm(systemBus[1], 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.gensokyo.uk/security/fortify/acl" "git.ophivana.moe/cat/fortify/acl"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.ophivana.moe/cat/fortify/internal/state"
"git.gensokyo.uk/security/fortify/internal/linux"
"git.gensokyo.uk/security/fortify/internal/system"
) )
const ( const (
@ -23,57 +22,44 @@ var (
ErrXDisplay = errors.New(display + " unset") ErrXDisplay = errors.New(display + " unset")
) )
func (seal *appSeal) shareDisplay(os linux.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.sys.setEnv(term, t)
} }
// set up wayland // set up wayland
if seal.et.Has(system.EWayland) { if seal.et.Has(state.EnableWayland) {
var wp string
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 if seal.wlDone == nil {
} else {
wp = path.Join(seal.RuntimePath, wd)
}
w := path.Join(seal.sys.runtime, "wayland-0")
seal.sys.bwrap.SetEnv[waylandDisplay] = w
if seal.directWayland {
// hardlink wayland socket // hardlink wayland socket
wp := path.Join(seal.RuntimePath, wd)
wpi := path.Join(seal.shareLocal, "wayland") wpi := path.Join(seal.shareLocal, "wayland")
seal.sys.Link(wp, wpi) w := path.Join(seal.sys.runtime, "wayland-0")
seal.sys.bwrap.Bind(wpi, w) seal.sys.link(wp, wpi)
seal.sys.setEnv(waylandDisplay, w)
seal.sys.bind(wpi, w, true)
// 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.updatePermTag(state.EnableWayland, wp, acl.Read, acl.Write, acl.Execute)
} else { } else {
wc := path.Join(seal.SharePath, "wayland") // set wayland socket path (e.g. `/run/user/%d/wayland-%d`)
wt := path.Join(wc, seal.id) seal.wl = path.Join(seal.RuntimePath, wd)
seal.sys.Ensure(wc, 0711)
appID := seal.fid
if appID == "" {
// use instance ID in case app id is not set
appID = "moe.ophivana.fortify." + seal.id
}
seal.sys.Wayland(wt, wp, appID, seal.id)
seal.sys.bwrap.Bind(wt, w)
} }
} }
// 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.us) seal.sys.changeHosts(seal.sys.Username)
seal.sys.bwrap.SetEnv[display] = d seal.sys.setEnv(display, d)
seal.sys.bwrap.Bind("/tmp/.X11-unix", "/tmp/.X11-unix") seal.sys.bind("/tmp/.X11-unix", "/tmp/.X11-unix", true)
} }
} }

View File

@ -4,11 +4,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os"
"path" "path"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.ophivana.moe/cat/fortify/internal/state"
"git.gensokyo.uk/security/fortify/internal/linux"
"git.gensokyo.uk/security/fortify/internal/system"
) )
const ( const (
@ -25,8 +24,13 @@ var (
ErrPulseMode = errors.New("unexpected pulse socket mode") ErrPulseMode = errors.New("unexpected pulse socket mode")
) )
func (seal *appSeal) sharePulse(os linux.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
} }
@ -35,50 +39,49 @@ func (seal *appSeal) sharePulse(os linux.System) error {
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`) // check 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 // hard link pulse socket into target-executable share
psi := path.Join(seal.shareLocal, "pulse") psi := path.Join(seal.shareLocal, "pulse")
p := path.Join(seal.sys.runtime, "pulse", "native") p := path.Join(seal.sys.runtime, "pulse", "native")
seal.sys.Link(ps, psi) seal.sys.link(ps, psi)
seal.sys.bwrap.Bind(psi, p) seal.sys.bind(psi, p, true)
seal.sys.bwrap.SetEnv[pulseServer] = "unix:" + p seal.sys.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.sys.setEnv(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 linux.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 +91,8 @@ func discoverPulseCookie(os linux.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 +105,7 @@ func discoverPulseCookie(os linux.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 +113,7 @@ func discoverPulseCookie(os linux.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,9 @@ package app
import ( import (
"path" "path"
"git.gensokyo.uk/security/fortify/acl" "git.ophivana.moe/cat/fortify/acl"
"git.gensokyo.uk/security/fortify/internal/system" "git.ophivana.moe/cat/fortify/helper/bwrap"
"git.ophivana.moe/cat/fortify/internal/state"
) )
const ( const (
@ -16,24 +17,58 @@ 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`) // mount tmpfs on inner runtime (e.g. `/run/user/%d`)
seal.sys.bwrap.Tmpfs("/run/user", 1*1024*1024) seal.sys.bwrap.Tmpfs = append(seal.sys.bwrap.Tmpfs,
seal.sys.bwrap.Tmpfs(seal.sys.runtime, 8*1024*1024) bwrap.PermConfig[bwrap.TmpfsConfig]{
Path: bwrap.TmpfsConfig{
// point to inner runtime path `/run/user/%d` Size: 1 * 1024 * 1024,
seal.sys.bwrap.SetEnv[xdgRuntimeDir] = seal.sys.runtime Dir: "/run/user",
seal.sys.bwrap.SetEnv[xdgSessionClass] = "user" },
seal.sys.bwrap.SetEnv[xdgSessionType] = "tty" },
bwrap.PermConfig[bwrap.TmpfsConfig]{
Path: bwrap.TmpfsConfig{
Size: 8 * 1024 * 1024,
Dir: seal.sys.runtime,
},
},
)
// 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) seal.sys.updatePermTag(state.EnableLength, 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.updatePermTag(state.EnableLength, seal.RuntimePath, acl.Execute)
seal.sys.UpdatePermType(system.User, seal.RuntimePath, acl.Execute)
// 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.String())
seal.sys.ensureEphemeral(seal.share, 0701)
// ensure process-specific share local to XDG_RUNTIME_DIR (e.g. `/run/user/%d/fortify/%s`) // ensure process-specific share local to XDG_RUNTIME_DIR (e.g. `/run/user/%d/fortify/%s`)
seal.shareLocal = path.Join(seal.RunDirPath, seal.id) seal.shareLocal = path.Join(seal.RunDirPath, seal.id.String())
seal.sys.Ephemeral(system.Process, seal.shareLocal, 0700) seal.sys.ensureEphemeral(seal.shareLocal, 0700)
seal.sys.UpdatePerm(seal.shareLocal, acl.Execute) seal.sys.updatePerm(seal.shareLocal, acl.Execute)
}
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.updatePermTag(state.EnableLength, 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.updatePermTag(state.EnableLength, targetRuntime, acl.Read, acl.Write, acl.Execute)
// point to ensured runtime path
seal.sys.setEnv(xdgRuntimeDir, targetRuntime)
seal.sys.setEnv(xdgSessionClass, "user")
seal.sys.setEnv(xdgSessionType, "tty")
return targetRuntime
} }

View File

@ -1,11 +1,8 @@
package app package app
import ( import (
"os"
"path" "path"
"git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/internal/linux"
"git.gensokyo.uk/security/fortify/internal/system"
) )
const ( const (
@ -14,64 +11,33 @@ const (
// shareSystem queues various system-related actions // shareSystem queues various system-related actions
func (seal *appSeal) shareSystem() { 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, 0711)
// 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, 0711)
// 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.as)
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 linux.System) {
// look up shell // look up shell
sh := "/bin/sh" sh := "/bin/sh"
if s, ok := os.LookupEnv(shell); ok { if s, ok := os.LookupEnv(shell); ok {
seal.sys.bwrap.SetEnv[shell] = s seal.sys.setEnv(shell, s)
sh = s sh = s
} }
// generate /etc/passwd // generate /etc/passwd
passwdPath := path.Join(seal.share, "passwd") passwdPath := path.Join(seal.share, "passwd")
username := "chronos" username := "chronos"
if seal.sys.user.username != "" { if seal.sys.Username != "" {
username = seal.sys.user.username username = seal.sys.Username
seal.sys.setEnv("USER", seal.sys.Username)
} }
homeDir := "/var/empty" homeDir := "/var/empty"
if seal.sys.user.home != "" { if seal.sys.HomeDir != "" {
homeDir = seal.sys.user.home homeDir = seal.sys.HomeDir
seal.sys.setEnv("HOME", seal.sys.HomeDir)
} }
passwd := username + ":x:65534:65534:Fortify:" + homeDir + ":" + sh + "\n"
// bind home directory seal.sys.writeFile(passwdPath, []byte(passwd))
seal.sys.bwrap.Bind(seal.sys.user.data, homeDir, false, true)
seal.sys.bwrap.Chdir = homeDir
seal.sys.bwrap.SetEnv["USER"] = username
seal.sys.bwrap.SetEnv["HOME"] = homeDir
passwd := username + ":x:" + seal.sys.mappedIDString + ":" + seal.sys.mappedIDString + ":Fortify:" + homeDir + ":" + sh + "\n"
seal.sys.Write(passwdPath, passwd)
// write /etc/group // write /etc/group
groupPath := path.Join(seal.share, "group") groupPath := path.Join(seal.share, "group")
seal.sys.Write(groupPath, "fortify:x:"+seal.sys.mappedIDString+":\n") seal.sys.writeFile(groupPath, []byte("fortify:x:65534:\n"))
// bind /etc/passwd and /etc/group // bind /etc/passwd and /etc/group
seal.sys.bwrap.Bind(passwdPath, "/etc/passwd") seal.sys.bind(passwdPath, "/etc/passwd", true)
seal.sys.bwrap.Bind(groupPath, "/etc/group") seal.sys.bind(groupPath, "/etc/group", true)
} }

View File

@ -3,28 +3,35 @@ package app
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path"
"path/filepath" "path/filepath"
"strings" "strconv"
"time"
shim0 "git.gensokyo.uk/security/fortify/cmd/fshim/ipc" "git.ophivana.moe/cat/fortify/helper"
"git.gensokyo.uk/security/fortify/cmd/fshim/ipc/shim" "git.ophivana.moe/cat/fortify/internal/shim"
"git.gensokyo.uk/security/fortify/helper" "git.ophivana.moe/cat/fortify/internal/state"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.ophivana.moe/cat/fortify/internal/verbose"
"git.gensokyo.uk/security/fortify/internal/state"
"git.gensokyo.uk/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
// ShimError encapsulates errors returned by shim.ServeConfig.
ShimError 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 // resolve exec paths
shimExec := [2]string{helper.BubblewrapName} shimExec := [3]string{a.seal.sys.executable, helper.BubblewrapName}
if len(a.seal.command) > 0 { if len(a.seal.command) > 0 {
shimExec[1] = a.seal.command[0] shimExec[2] = a.seal.command[0]
} }
for i, n := range shimExec { for i, n := range shimExec {
if len(n) == 0 { if len(n) == 0 {
@ -34,54 +41,71 @@ func (a *app) Start() error {
if s, err := exec.LookPath(n); err == nil { if s, err := exec.LookPath(n); err == nil {
shimExec[i] = s shimExec[i] = s
} else { } else {
return fmsg.WrapError(err, return (*ProcessError)(wrapError(err, fmt.Sprintf("cannot find %q: %v", n, err)))
fmt.Sprintf("executable file %q not found in $PATH", n))
} }
} }
} }
// construct shim manager if err := a.seal.sys.commit(); err != nil {
a.shim = shim.New( return err
uint32(a.seal.sys.UID()), }
a.seal.sys.user.as,
a.seal.sys.user.supp, // select command builder
&shim0.Payload{ var commandBuilder func(shimEnv string) (args []string)
switch a.seal.launchOption {
case LaunchMethodSudo:
commandBuilder = a.commandBuilderSudo
case LaunchMethodMachineCtl:
commandBuilder = a.commandBuilderMachineCtl
default:
panic("unreachable")
}
// configure child process
confSockPath := path.Join(a.seal.share, "shim")
a.cmd = exec.Command(a.seal.toolPath, commandBuilder(shim.EnvShim+"="+confSockPath)...)
a.cmd.Env = []string{}
a.cmd.Stdin = os.Stdin
a.cmd.Stdout = os.Stdout
a.cmd.Stderr = os.Stderr
a.cmd.Dir = a.seal.RunDirPath
if wls, err := shim.ServeConfig(confSockPath, &shim.Payload{
Argv: a.seal.command, Argv: a.seal.command,
Exec: shimExec, Exec: shimExec,
Bwrap: a.seal.sys.bwrap, Bwrap: a.seal.sys.bwrap,
WL: a.seal.wlDone != nil,
Verbose: fmsg.Verbose(), Verbose: verbose.Get(),
}, }, a.seal.wl, a.seal.wlDone); err != nil {
) return (*ShimError)(wrapError(err, "cannot listen on shim socket:", err))
// startup will go ahead, commit system setup
if err := a.seal.sys.Commit(); err != nil {
return err
}
a.seal.sys.needRevert = true
// export sync pipe from sys
a.seal.sys.bwrap.SetSync(a.seal.sys.Sync())
if startTime, err := a.shim.Start(); err != nil {
return err
} else { } else {
// shim start and setup success, create process state a.wayland = wls
}
// start shim
verbose.Println("starting shim as target user:", a.cmd)
if err := a.cmd.Start(); err != nil {
return (*ProcessError)(wrapError(err, "cannot start process:", err))
}
startTime := time.Now().UTC()
// create process state
sd := state.State{ sd := state.State{
ID: *a.id, PID: a.cmd.Process.Pid,
PID: a.shim.Unwrap().Process.Pid, Command: a.seal.command,
Config: a.ct.Unwrap(), Capability: a.seal.et,
Time: *startTime, Launcher: a.seal.toolPath,
Argv: a.cmd.Args,
Time: startTime,
} }
// register process state // register process state
var err0 = new(StateStoreError) var err = new(StateStoreError)
err0.Inner, err0.DoErr = a.seal.store.Do(a.seal.sys.user.aid, func(c state.Cursor) { err.Inner, err.DoErr = a.seal.store.Do(func(b state.Backend) {
err0.InnerErr = c.Save(&sd) err.InnerErr = b.Save(&sd)
}) })
a.seal.sys.saveState = true return err.equiv("cannot save process state:", err)
return err0.equiv("cannot save process state:")
}
} }
// StateStoreError is returned for a failed state save // StateStoreError is returned for a failed state save
@ -97,10 +121,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...)
} }
} }
@ -143,118 +167,81 @@ 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 := make(chan error, 1)
go func() { wait <- cmd.Wait() }()
select {
// wait for process and resolve exit code // wait for process and resolve exit code
case err := <-wait: if err := a.cmd.Wait(); err != nil {
if 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)
// alternative exit path when kill was unsuccessful
case err := <-a.shim.WaitFallback():
r = 255
if err != nil {
fmsg.Printf("cannot terminate shim on faulted setup: %v", err)
} else {
fmsg.VPrintln("alternative exit path selected")
}
}
} }
// child process exited, resume output verbose.Println("process", strconv.Itoa(a.cmd.Process.Pid), "exited with exit code", r)
fmsg.Resume()
// print queued up dbus messages // close wayland connection
if a.seal.dbusMsg != nil { if a.wayland != nil {
a.seal.dbusMsg(func(msgbuf []string) { close(a.seal.wlDone)
for _, msg := range msgbuf { if err := a.wayland.Close(); err != nil {
fmsg.Println(msg) fmt.Println("fortify: 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(a.seal.sys.user.aid, func(b state.Cursor) { 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(*a.id); err != nil {
return err return err
} }
}
// enablements of remaining launchers // enablements of remaining launchers
rt, ec := new(system.Enablements), new(system.Criteria) rt, tags := new(state.Enablements), new(state.Enablements)
ec.Enablements = new(system.Enablements) tags.Set(state.EnableLength + 1)
ec.Set(system.Process)
if states, err := b.Load(); err != nil { if states, err := b.Load(); err != nil {
return err return err
} else { } else {
if l := len(states); l == 0 { if l := len(states); l == 0 {
// cleanup globals as the final launcher // cleanup globals as the final launcher
fmsg.VPrintln("no other launchers active, will clean up globals") verbose.Println("no other launchers active, will clean up globals")
ec.Set(system.User) tags.Set(state.EnableLength)
} else { } else {
fmsg.VPrintf("found %d active launchers, cleaning up without globals", l) verbose.Printf("found %d active launchers, cleaning up without globals\n", l)
} }
// accumulate capabilities of other launchers // accumulate capabilities of other launchers
for i, s := range states { for _, s := range states {
if s.Config != nil { *rt |= s.Capability
*rt |= s.Config.Confinement.Enablements
} else {
fmsg.Printf("state entry %d does not contain config", i)
}
} }
} }
// invert accumulated enablements for cleanup // invert accumulated enablements for cleanup
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ { for i := state.Enablement(0); i < state.EnableLength; i++ {
if !rt.Has(i) { if !rt.Has(i) {
ec.Set(i) tags.Set(i)
} }
} }
if fmsg.Verbose() { if verbose.Get() {
labels := make([]string, 0, system.ELen+1) ct := make([]state.Enablement, 0, state.EnableLength)
for i := system.Enablement(0); i < system.Enablement(system.ELen+2); i++ { for i := state.Enablement(0); i < state.EnableLength; i++ {
if ec.Has(i) { if tags.Has(i) {
labels = append(labels, system.TypeString(i)) ct = append(ct, i)
} }
} }
if len(labels) > 0 { if len(ct) > 0 {
fmsg.VPrintln("reverting operations labelled", strings.Join(labels, ", ")) verbose.Println("will revert operations tagged", ct, "as no remaining launchers hold these enablements")
} }
} }
if a.seal.sys.needRevert { if err := a.seal.sys.revert(tags); err != nil {
if err := a.seal.sys.Revert(ec); err != nil {
return err.(RevertCompoundError) return err.(RevertCompoundError)
} }
}
return nil return nil
}() }()

View File

@ -1,71 +1,429 @@
package app package app
import ( import (
"git.gensokyo.uk/security/fortify/dbus" "errors"
"git.gensokyo.uk/security/fortify/helper/bwrap" "fmt"
"git.gensokyo.uk/security/fortify/internal/linux" "io/fs"
"git.gensokyo.uk/security/fortify/internal/system" "os"
"os/user"
"git.ophivana.moe/cat/fortify/acl"
"git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/cat/fortify/helper/bwrap"
"git.ophivana.moe/cat/fortify/internal"
"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 // wayland socket path if mediated wayland is enabled
wl string
// wait for wayland client to exit if mediated wayland is enabled,
// (wlDone == nil) determines whether mediated wayland setup is performed
wlDone chan struct{}
// default formatted XDG_RUNTIME_DIR of User // freedesktop application ID
runtime string fid string
// target user sealed from config // argv to start process with in the final confined environment
user appUser command []string
// persistent process state store
store state.Store
// mapped uid and gid in user namespace // uint8 representation of launch method sealed from config
mappedID int launchOption uint8
// string representation of mappedID // process-specific share directory path
mappedIDString string share string
// process-specific share directory path local to XDG_RUNTIME_DIR
shareLocal string
needRevert bool // path to launcher program
saveState bool toolPath string
*system.I // 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
} }
type appUser struct { // appSealTx contains the system-level component of the app seal
// full uid resolved by fsu type appSealTx struct {
bwrap *bwrap.Config
// 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, data pairs of temporarily available files
files [][2]string
// dst, src pairs of temporarily shared files
tmpfiles [][2]string
// dst, src pairs of temporarily hard linked files
hardlinks [][2]string
// default formatted XDG_RUNTIME_DIR of User
runtime string
// sealed path to fortify executable, used by shim
executable string
// target user UID as an integer
uid int uid int
// string representation of uid // target user sealed from config
us string *user.User
// supplementary group ids // prevents commit from happening twice
supp []string complete bool
// prevents cleanup from happening twice
closed bool
// application id // protected by upstream mutex
aid int }
// string representation of aid
as string
// home directory host path type appEnsureEntry struct {
data string path string
// app user home directory perm os.FileMode
home string remove bool
// passwd database username }
username string
// setEnv sets an environment variable for the child process
func (tx *appSealTx) setEnv(k, v string) {
tx.bwrap.SetEnv[k] = v
}
// bind mounts a directory within the sandbox
func (tx *appSealTx) bind(src, dest string, ro bool) {
if !ro {
tx.bwrap.Bind = append(tx.bwrap.Bind, [2]string{src, dest})
} else {
tx.bwrap.ROBind = append(tx.bwrap.ROBind, [2]string{src, dest})
}
}
// 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 {
tag state.Enablement
path string
perms []acl.Perm
}
func (e *appACLEntry) ts() string {
switch e.tag {
case state.EnableLength:
return "Global"
case state.EnableLength + 1:
return "Process"
default:
return e.tag.String()
}
}
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 untagged acl update action
func (tx *appSealTx) updatePerm(path string, perms ...acl.Perm) {
tx.updatePermTag(state.EnableLength+1, path, perms...)
}
// updatePermTag appends an acl update action
// Tagging with state.EnableLength sets cleanup to happen at final active launcher exit,
// while tagging with state.EnableLength+1 will unconditionally clean up on exit.
func (tx *appSealTx) updatePermTag(tag state.Enablement, path string, perms ...acl.Perm) {
tx.acl = append(tx.acl, &appACLEntry{tag, path, perms})
}
// changeHosts appends target username of an X11 ChangeHosts action
func (tx *appSealTx) changeHosts(username string) {
tx.xhost = append(tx.xhost, username)
}
// writeFile appends a files action
func (tx *appSealTx) writeFile(dst string, data []byte) {
tx.files = append(tx.files, [2]string{dst, string(data)})
tx.updatePerm(dst, acl.Read)
tx.bind(dst, dst, true)
}
// 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)
tx.bind(dst, dst, true)
}
// link appends a hardlink action
func (tx *appSealTx) link(oldname, newname string) {
tx.hardlinks = append(tx.hardlinks, [2]string{oldname, newname})
}
type (
ChangeHostsError BaseError
EnsureDirError BaseError
TmpfileError BaseError
DBusStartError BaseError
ACLUpdateError BaseError
)
// commit applies recorded actions
// order: xhost, mkdir, files, tmpfiles, hardlinks, dbus, acl
func (tx *appSealTx) commit() error {
if tx.complete {
panic("seal transaction committed twice")
}
tx.complete = true
txp := &appSealTx{User: tx.User, bwrap: &bwrap.Config{SetEnv: make(map[string]string)}}
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
tags := new(state.Enablements)
for e := state.Enablement(0); e < state.EnableLength+2; e++ {
tags.Set(e)
}
if err := txp.revert(tags); 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)
}
}
}
// write files
for _, file := range tx.files {
verbose.Println("writing", len(file[1]), "bytes of data to", file[0])
if err := os.WriteFile(file[0], []byte(file[1]), 0600); err != nil {
return (*TmpfileError)(wrapError(err,
fmt.Sprintf("cannot write file '%s': %s", file[0], err)))
} else {
// register partial commit
txp.writeFile(file[0], make([]byte, 0)) // data not necessary for revert
}
}
// 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])
}
}
// create hardlinks
for _, link := range tx.hardlinks {
verbose.Println("creating hardlink", link[1], "from", link[0])
if err := os.Link(link[0], link[1]); err != nil {
return (*TmpfileError)(wrapError(err,
fmt.Sprintf("cannot create hardlink '%s' from '%s': %s", link[1], link[0], err)))
} else {
// register partial commit
txp.link(link[0], link[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, "tag:", e.ts(), "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.updatePermTag(e.tag, e.path, e.perms...)
}
}
// disarm partial commit rollback
txp = nil
return nil
}
// revert rolls back recorded actions
// order: acl, dbus, hardlinks, tmpfiles, files, mkdir, xhost
// errors are printed but not treated as fatal
func (tx *appSealTx) revert(tags *state.Enablements) 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)
}
// revert ACLs
for _, e := range tx.acl {
if tags.Has(e.tag) {
verbose.Println("stripping ACL", e, "uid:", tx.Uid, "tag:", e.ts(), "path:", e.path)
err := acl.UpdatePerm(e.path, tx.uid)
joinError(err, fmt.Sprintf("cannot strip ACL entry from '%s': %s", e.path, err))
} else {
verbose.Println("skipping ACL", e, "uid:", tx.Uid, "tag:", e.ts(), "path:", e.path)
}
}
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 hardlinks
for _, link := range tx.hardlinks {
verbose.Println("removing hardlink", link[1])
err := os.Remove(link[1])
joinError(err, fmt.Sprintf("cannot remove hardlink '%s': %s", link[1], 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 files
for _, file := range tx.files {
verbose.Println("removing file", file[0])
err := os.Remove(file[0])
joinError(err, fmt.Sprintf("cannot remove file '%s': %s", file[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 tags.Has(state.EnableX) {
// 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 linux.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) seal.shareSystem()
if err := seal.shareDisplay(os); err != nil { targetRuntime := seal.shareRuntimeChild()
verbose.Printf("child runtime data dir '%s' configured\n", targetRuntime)
if err := seal.shareDisplay(); err != nil {
return err return err
} }
if err := seal.sharePulse(os); err != nil { if err := seal.sharePulse(); err != nil {
return err return err
} }
@ -76,11 +434,12 @@ func (seal *appSeal) shareAll(bus [2]*dbus.Config, os linux.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
for _, dest := range seal.sys.override {
seal.sys.bwrap.Tmpfs(dest, 8*1024)
} }
return nil return nil

View File

@ -1,12 +0,0 @@
package internal
const compPoison = "INVALIDINVALIDINVALIDINVALIDINVALID"
var (
Version = compPoison
)
// Check validates string value set at compile time.
func Check(s string) (string, bool) {
return s, s != compPoison && s != ""
}

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,97 +0,0 @@
package fmsg
import (
"os"
"sync"
"sync/atomic"
)
var (
wstate atomic.Bool
dropped atomic.Uint64
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
}
}
}()
}
// queue submits ops to msgbuf but drops messages
// when the buffer is full and dequeue is withholding
func queue(op dOp) {
queueSync.Add(1)
select {
case msgbuf <- op:
default:
// send the op anyway if not withholding
// as dequeue will get to it eventually
if !wstate.Load() {
msgbuf <- op
} else {
queueSync.Done()
// increment dropped message count
dropped.Add(1)
}
}
}
type dOp interface{ Do() }
func Exit(code int) {
queueSync.Wait()
os.Exit(code)
}
func Suspend() {
dequeueOnce.Do(dequeue)
if wstate.CompareAndSwap(false, true) {
queueSync.Wait()
withhold <- struct{}{}
}
}
func Resume() {
dequeueOnce.Do(dequeue)
if wstate.CompareAndSwap(true, false) {
withhold <- struct{}{}
if d := dropped.Swap(0); d != 0 {
Printf("dropped %d messages during withhold", d)
}
}
}
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,40 +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)
queue(dPrint(v))
}
func Printf(format string, v ...any) {
dequeueOnce.Do(dequeue)
queue(&dPrintf{format, v})
}
func Println(v ...any) {
dequeueOnce.Do(dequeue)
queue(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...)
}
}

164
internal/init/main.go Normal file
View File

@ -0,0 +1,164 @@
package init0
import (
"encoding/gob"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"os/signal"
"path"
"strconv"
"syscall"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
// everything beyond this point runs within pid namespace
// proceed with caution!
func doInit(fd uintptr) {
// 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 {
fmt.Println("fortify-init: cannot re-exec self:", err)
// continue anyway
}
}
verbose.Prefix = "fortify-init:"
var payload Payload
p := os.NewFile(fd, "config-stream")
if p == nil {
fmt.Println("fortify-init: invalid config descriptor")
os.Exit(1)
}
if err := gob.NewDecoder(p).Decode(&payload); err != nil {
fmt.Println("fortify-init: cannot decode init payload:", err)
os.Exit(1)
} else {
// sharing stdout with parent
// USE WITH CAUTION
verbose.Set(payload.Verbose)
// child does not need to see this
if err = os.Unsetenv(EnvInit); err != nil {
fmt.Println("fortify-init: cannot unset", EnvInit+":", err)
// not fatal
} else {
verbose.Println("received configuration")
}
}
// close config fd
if err := p.Close(); err != nil {
fmt.Println("fortify-init: 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 {
fmt.Println("fortify-init: prctl(PR_SET_PDEATHSIG, SIGKILL):", errno.Error())
os.Exit(1)
}
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 {
fmt.Printf("fortify-init: cannot start %q: %v", payload.Argv0, err)
os.Exit(1)
}
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) {
fmt.Println("fortify-init: unexpected wait4 response:", err)
}
close(done)
}()
r := 2
for {
select {
case s := <-sig:
verbose.Println("received", s.String())
os.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
}
}
case <-done:
os.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 {
fmt.Printf("fortify-init: cannot parse %q: %v", s, err)
os.Exit(1)
} else {
doInit(uintptr(fd))
}
panic("unreachable")
}
}
}

View File

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

View File

@ -1,70 +0,0 @@
package linux
import (
"io"
"io/fs"
"os/user"
"path"
"strconv"
"git.gensokyo.uk/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)
// LookupGroup provides [user.LookupGroup].
LookupGroup(name string) (*user.Group, 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)
// Stdout provides [os.Stdout].
Stdout() io.Writer
// Paths returns a populated [Paths] struct.
Paths() Paths
// Uid invokes fsu and returns target uid.
Uid(aid int) (int, error)
}
// 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)
}

View File

@ -1,88 +0,0 @@
package linux
import (
"io"
"io/fs"
"os"
"os/exec"
"os/user"
"strconv"
"sync"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
// Std implements System using the standard library.
type Std struct {
paths Paths
pathsOnce sync.Once
uidOnce sync.Once
uidCopy map[int]struct {
uid int
err error
}
uidMu sync.RWMutex
}
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) LookupGroup(name string) (*user.Group, error) { return user.LookupGroup(name) }
func (s *Std) ReadDir(name string) ([]os.DirEntry, error) { return os.ReadDir(name) }
func (s *Std) 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) }
func (s *Std) Stdout() io.Writer { return os.Stdout }
const xdgRuntimeDir = "XDG_RUNTIME_DIR"
func (s *Std) Paths() Paths {
s.pathsOnce.Do(func() { CopyPaths(s, &s.paths) })
return s.paths
}
func (s *Std) Uid(aid int) (int, error) {
s.uidOnce.Do(func() {
s.uidCopy = make(map[int]struct {
uid int
err error
})
})
s.uidMu.RLock()
if u, ok := s.uidCopy[aid]; ok {
s.uidMu.RUnlock()
return u.uid, u.err
}
s.uidMu.RUnlock()
s.uidMu.Lock()
defer s.uidMu.Unlock()
u := struct {
uid int
err error
}{}
defer func() { s.uidCopy[aid] = u }()
u.uid = -1
if fsu, ok := internal.Check(internal.Fsu); !ok {
fmsg.Fatal("invalid fsu path, this copy of fshim is not compiled correctly")
panic("unreachable")
} else {
cmd := exec.Command(fsu)
cmd.Path = fsu
cmd.Stderr = os.Stderr // pass through fatal messages
cmd.Env = []string{"FORTIFY_APP_ID=" + strconv.Itoa(aid)}
cmd.Dir = "/"
var p []byte
if p, u.err = cmd.Output(); u.err == nil {
u.uid, u.err = strconv.Atoi(string(p))
}
return u.uid, u.err
}
}

View File

@ -1,12 +0,0 @@
package internal
import "path"
var (
Fsu = compPoison
Finit = compPoison
)
func Path(p string) (string, bool) {
return p, p != compPoison && p != "" && path.IsAbs(p)
}

View File

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

View File

@ -1,42 +0,0 @@
package proc
import (
"encoding/gob"
"errors"
"os"
"strconv"
)
var (
ErrNotSet = errors.New("environment variable not set")
ErrInvalid = errors.New("bad file descriptor")
)
func Setup(extraFiles *[]*os.File) (int, *gob.Encoder, error) {
if r, w, err := os.Pipe(); err != nil {
return -1, nil, err
} else {
fd := 3 + len(*extraFiles)
*extraFiles = append(*extraFiles, r)
return fd, gob.NewEncoder(w), nil
}
}
func Receive(key string, e any) (func() error, error) {
var setup *os.File
if s, ok := os.LookupEnv(key); !ok {
return nil, ErrNotSet
} else {
if fd, err := strconv.Atoi(s); err != nil {
return nil, err
} else {
setup = os.NewFile(uintptr(fd), "setup")
if setup == nil {
return nil, ErrInvalid
}
}
}
return func() error { return setup.Close() }, gob.NewDecoder(setup).Decode(e)
}

View File

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

188
internal/shim/main.go Normal file
View File

@ -0,0 +1,188 @@
package shim
import (
"encoding/gob"
"errors"
"flag"
"fmt"
"net"
"os"
"path"
"strconv"
"syscall"
"git.ophivana.moe/cat/fortify/helper"
init0 "git.ophivana.moe/cat/fortify/internal/init"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
// everything beyond this point runs as target user
// proceed with caution!
func doShim(socket string) {
// 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 {
fmt.Println("fortify-shim: cannot re-exec self:", err)
// continue anyway
}
}
verbose.Prefix = "fortify-shim:"
// dial setup socket
var conn *net.UnixConn
if c, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: socket, Net: "unix"}); err != nil {
fmt.Println("fortify-shim: cannot dial setup socket:", err)
os.Exit(1)
} else {
conn = c
}
// decode payload gob stream
var payload Payload
if err := gob.NewDecoder(conn).Decode(&payload); err != nil {
fmt.Println("fortify-shim: cannot decode shim payload:", err)
os.Exit(1)
} else {
// sharing stdout with parent
// USE WITH CAUTION
verbose.Set(payload.Verbose)
}
if payload.Bwrap == nil {
fmt.Println("fortify-shim: bwrap config not supplied")
os.Exit(1)
}
// receive wayland fd over socket
wfd := -1
if payload.WL {
if fd, err := receiveWLfd(conn); err != nil {
fmt.Println("fortify-shim: cannot receive wayland fd:", err)
os.Exit(1)
} else {
wfd = fd
}
}
// close setup socket
if err := conn.Close(); err != nil {
fmt.Println("fortify-shim: 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 {
fmt.Println("fortify-shim: no command was specified and $SHELL was unset")
os.Exit(1)
}
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 {
fmt.Println("fortify-shim: cannot pipe:", err)
os.Exit(1)
} else {
conf.SetEnv[init0.EnvInit] = strconv.Itoa(3 + len(extraFiles))
extraFiles = append(extraFiles, r)
verbose.Println("transmitting config to init")
go func() {
// stream config to pipe
if err = gob.NewEncoder(w).Encode(&ic); err != nil {
fmt.Println("fortify-shim: cannot transmit init config:", err)
os.Exit(1)
}
}()
}
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 {
fmt.Println("fortify-shim: malformed sandbox config:", err)
os.Exit(1)
} else {
cmd := b.Unwrap()
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.ExtraFiles = extraFiles
if verbose.Get() {
verbose.Println("bwrap args:", conf.Args())
}
// run and pass through exit code
if err = b.Start(); err != nil {
fmt.Println("fortify-shim: cannot start target process:", err)
os.Exit(1)
} else if err = b.Wait(); err != nil {
verbose.Println("wait:", err)
}
if b.Unwrap().ProcessState != nil {
os.Exit(b.Unwrap().ProcessState.ExitCode())
} else {
os.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")
}
}
}

82
internal/shim/parent.go Normal file
View File

@ -0,0 +1,82 @@
package shim
import (
"encoding/gob"
"errors"
"fmt"
"net"
"os"
"syscall"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
// called in the parent process
func ServeConfig(socket string, payload *Payload, wl string, done chan struct{}) (*net.UnixConn, error) {
var ws *net.UnixConn
if payload.WL {
if f, err := net.DialUnix("unix", nil, &net.UnixAddr{Name: wl, Net: "unix"}); err != nil {
return nil, err
} else {
verbose.Println("connected to wayland at", wl)
ws = f
}
}
if c, err := net.ListenUnix("unix", &net.UnixAddr{Name: socket, Net: "unix"}); err != nil {
return nil, err
} else {
verbose.Println("configuring shim on socket", socket)
if err = os.Chmod(socket, 0777); err != nil {
fmt.Println("fortify: cannot change permissions of shim setup socket:", err)
}
go func() {
var conn *net.UnixConn
if conn, err = c.AcceptUnix(); err != nil {
fmt.Println("fortify: cannot accept connection from shim:", err)
} else {
if err = gob.NewEncoder(conn).Encode(*payload); err != nil {
fmt.Println("fortify: cannot stream shim payload:", err)
return
}
if payload.WL {
// get raw connection
var rc syscall.RawConn
if rc, err = ws.SyscallConn(); err != nil {
fmt.Println("fortify: cannot obtain raw wayland connection:", err)
return
} else {
go func() {
// pass wayland socket fd
if err = rc.Control(func(fd uintptr) {
if _, _, err = conn.WriteMsgUnix(nil, syscall.UnixRights(int(fd)), nil); err != nil {
fmt.Println("fortify: cannot pass wayland connection to shim:", err)
return
}
_ = conn.Close()
// block until shim exits
<-done
verbose.Println("releasing wayland connection")
}); err != nil {
fmt.Println("fortify: cannot obtain wayland connection fd:", err)
}
}()
}
} else {
_ = conn.Close()
}
}
if err = c.Close(); err != nil {
fmt.Println("fortify: cannot close shim socket:", err)
}
if err = os.Remove(socket); err != nil && !errors.Is(err, os.ErrNotExist) {
fmt.Println("fortify: cannot remove dangling shim socket:", err)
}
}()
return ws, nil
}
}

19
internal/shim/payload.go Normal file
View File

@ -0,0 +1,19 @@
package shim
import "git.ophivana.moe/cat/fortify/helper/bwrap"
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
}

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,292 +0,0 @@
package state
import (
"encoding/gob"
"errors"
"fmt"
"io/fs"
"os"
"path"
"strconv"
"sync"
"syscall"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
// fine-grained locking and access
type multiStore struct {
base string
// initialised backends
backends *sync.Map
lock sync.RWMutex
}
func (s *multiStore) Do(aid int, f func(c Cursor)) (bool, error) {
s.lock.RLock()
defer s.lock.RUnlock()
// load or initialise new backend
b := new(multiBackend)
if v, ok := s.backends.LoadOrStore(aid, b); ok {
b = v.(*multiBackend)
} else {
b.lock.Lock()
b.path = path.Join(s.base, strconv.Itoa(aid))
// ensure directory
if err := os.MkdirAll(b.path, 0700); err != nil && !errors.Is(err, fs.ErrExist) {
s.backends.CompareAndDelete(aid, b)
return false, err
}
// open locker file
if l, err := os.OpenFile(b.path+".lock", os.O_RDWR|os.O_CREATE, 0600); err != nil {
s.backends.CompareAndDelete(aid, b)
return false, err
} else {
b.lockfile = l
}
b.lock.Unlock()
}
// lock backend
if err := b.lockFile(); err != nil {
return false, err
}
// expose backend methods without exporting the pointer
c := new(struct{ *multiBackend })
c.multiBackend = b
f(b)
// disable access to the backend on a best-effort basis
c.multiBackend = nil
// unlock backend
return true, b.unlockFile()
}
func (s *multiStore) List() ([]int, error) {
var entries []os.DirEntry
// read base directory to get all aids
if v, err := os.ReadDir(s.base); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
} else {
entries = v
}
aidsBuf := make([]int, 0, len(entries))
for _, e := range entries {
// skip non-directories
if !e.IsDir() {
fmsg.VPrintf("skipped non-directory entry %q", e.Name())
continue
}
// skip non-numerical names
if v, err := strconv.Atoi(e.Name()); err != nil {
fmsg.VPrintf("skipped non-aid entry %q", e.Name())
continue
} else {
if v < 0 || v > 9999 {
fmsg.VPrintf("skipped out of bounds entry %q", e.Name())
continue
}
aidsBuf = append(aidsBuf, v)
}
}
return append([]int(nil), aidsBuf...), nil
}
func (s *multiStore) Close() error {
s.lock.Lock()
defer s.lock.Unlock()
var errs []error
s.backends.Range(func(_, value any) bool {
b := value.(*multiBackend)
errs = append(errs, b.close())
return true
})
return errors.Join(errs...)
}
type multiBackend struct {
path string
// created/opened by prepare
lockfile *os.File
lock sync.RWMutex
}
func (b *multiBackend) filename(id *fst.ID) string {
return path.Join(b.path, id.String())
}
func (b *multiBackend) lockFileAct(lt int) (err error) {
op := "LockAct"
switch lt {
case syscall.LOCK_EX:
op = "Lock"
case syscall.LOCK_UN:
op = "Unlock"
}
for {
err = syscall.Flock(int(b.lockfile.Fd()), lt)
if !errors.Is(err, syscall.EINTR) {
break
}
}
if err != nil {
return &fs.PathError{
Op: op,
Path: b.lockfile.Name(),
Err: err,
}
}
return nil
}
func (b *multiBackend) lockFile() error {
return b.lockFileAct(syscall.LOCK_EX)
}
func (b *multiBackend) unlockFile() error {
return b.lockFileAct(syscall.LOCK_UN)
}
// reads all launchers in simpleBackend
// file contents are ignored if decode is false
func (b *multiBackend) load(decode bool) (Entries, error) {
b.lock.RLock()
defer b.lock.RUnlock()
// read directory contents, should only contain files named after ids
var entries []os.DirEntry
if pl, err := os.ReadDir(b.path); err != nil {
return nil, err
} else {
entries = pl
}
// allocate as if every entry is valid
// since that should be the case assuming no external interference happens
r := make(Entries, len(entries))
for _, e := range entries {
if e.IsDir() {
return nil, fmt.Errorf("unexpected directory %q in store", e.Name())
}
id := new(fst.ID)
if err := fst.ParseAppID(id, e.Name()); err != nil {
return nil, err
}
// run in a function to better handle file closing
if err := func() error {
// open state file for reading
if f, err := os.Open(path.Join(b.path, e.Name())); err != nil {
return err
} else {
defer func() {
if f.Close() != nil {
// unreachable
panic("foreign state file closed prematurely")
}
}()
s := new(State)
r[*id] = s
// append regardless, but only parse if required, used to implement Len
if decode {
if err = gob.NewDecoder(f).Decode(s); err != nil {
return err
}
if s.ID != *id {
return fmt.Errorf("state entry %s has unexpected id %s", id, &s.ID)
}
}
return nil
}
}(); err != nil {
return nil, err
}
}
return r, nil
}
// Save writes process state to filesystem
func (b *multiBackend) Save(state *State) error {
b.lock.Lock()
defer b.lock.Unlock()
if state.Config == nil {
return errors.New("state does not contain config")
}
statePath := b.filename(&state.ID)
// create and open state data file
if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil {
return err
} else {
defer func() {
if f.Close() != nil {
// unreachable
panic("state file closed prematurely")
}
}()
// encode into state file
return gob.NewEncoder(f).Encode(state)
}
}
func (b *multiBackend) Destroy(id fst.ID) error {
b.lock.Lock()
defer b.lock.Unlock()
return os.Remove(b.filename(&id))
}
func (b *multiBackend) Load() (Entries, error) {
return b.load(true)
}
func (b *multiBackend) Len() (int, error) {
// rn consists of only nil entries but has the correct length
rn, err := b.load(false)
return len(rn), err
}
func (b *multiBackend) close() error {
b.lock.Lock()
defer b.lock.Unlock()
err := b.lockfile.Close()
if err == nil || errors.Is(err, os.ErrInvalid) || errors.Is(err, os.ErrClosed) {
return nil
}
return err
}
// NewMulti returns an instance of the multi-file store.
func NewMulti(runDir string) Store {
b := new(multiStore)
b.base = path.Join(runDir, "state")
b.backends = new(sync.Map)
return b
}

View File

@ -1,11 +0,0 @@
package state_test
import (
"testing"
"git.gensokyo.uk/security/fortify/internal/state"
)
func TestMulti(t *testing.T) {
testStore(t, state.NewMulti(t.TempDir()))
}

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