Compare commits

..

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

223 changed files with 8057 additions and 14633 deletions

46
.gitea/workflows/nix.yml Normal file
View File

@ -0,0 +1,46 @@
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,24 +1,53 @@
name: Release name: Create distribution
on: on:
push: push:
tags: tags:
- 'v*' - '*'
jobs: jobs:
release: release:
name: Create release name: Release
runs-on: nix 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:
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: Build for release - name: Build for release
run: nix build --print-out-paths --print-build-logs .#dist run: FORTIFY_VERSION='${{ github.ref_name }}' ./dist/release.sh
- name: Release - name: Release
id: use-go-action
uses: https://gitea.com/actions/release-action@main uses: https://gitea.com/actions/release-action@main
with: with:
files: |- files: |-
result/fortify-** dist/fortify-**
api_key: '${{secrets.RELEASE_TOKEN}}' api_key: '${{secrets.RELEASE_TOKEN}}'

View File

@ -1,130 +1,62 @@
name: Test name: Tests
on: on:
- push - push
- pull_request - pull_request
jobs: jobs:
fortify: test:
name: Fortify name: Go tests
runs-on: nix runs-on: ubuntu-latest
container:
image: node:16-bookworm-slim
steps: steps:
- name: Checkout - name: Enable backports
uses: actions/checkout@v4 run: >-
echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list.d/backports.list
if: ${{ runner.os == 'Linux' }}
- name: Run NixOS test - name: Ensure environment
run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.fortify run: >-
apt-get update && apt-get install -y curl wget sudo libxml2
if: ${{ runner.os == 'Linux' }}
- name: Upload test output - name: Get dependencies
uses: actions/upload-artifact@v3 uses: awalsh128/cache-apt-pkgs-action@latest
with: with:
name: "fortify-vm-output" packages: acl git gcc pkg-config libwayland-dev wayland-protocols/bookworm-backports libxcb1-dev libacl1-dev
path: result/* version: 1.0
retention-days: 1 #execute_install_scripts: true
if: ${{ runner.os == 'Linux' }}
race:
name: Fortify (race detector)
runs-on: nix
steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Run NixOS test
run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.race
- name: Upload test output
uses: actions/upload-artifact@v3
with: with:
name: "fortify-race-vm-output" fetch-depth: 0
path: result/*
retention-days: 1
sandbox: - name: Setup go
name: Sandbox uses: https://github.com/actions/setup-go@v5
runs-on: nix
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run NixOS test
run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.sandbox
- name: Upload test output
uses: actions/upload-artifact@v3
with: with:
name: "sandbox-vm-output" go-version: '>=1.23.0'
path: result/*
retention-days: 1
sandbox-race: - name: Go generate
name: Sandbox (race detector) run: >-
runs-on: nix go generate ./...
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run NixOS test - name: Run tests
run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.sandbox-race run: >-
go test ./...
- name: Upload test output
uses: actions/upload-artifact@v3
with:
name: "sandbox-race-vm-output"
path: result/*
retention-days: 1
fpkg:
name: Fpkg
runs-on: nix
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run NixOS test
run: nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.fpkg
- name: Upload test output
uses: actions/upload-artifact@v3
with:
name: "fpkg-vm-output"
path: result/*
retention-days: 1
check:
name: Flake checks
needs:
- fortify
- race
- sandbox
- sandbox-race
- fpkg
runs-on: nix
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run checks
run: nix --print-build-logs --experimental-features 'nix-command flakes' flake check
dist:
name: Create distribution
runs-on: nix
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build for test - name: Build for test
id: build-test id: build-test
run: >- run: >-
export FORTIFY_REV="$(git rev-parse --short HEAD)" && FORTIFY_VERSION="$(git rev-parse --short HEAD)"
sed -i.old 's/version = /version = "0.0.0-'$FORTIFY_REV'"; # version = /' package.nix && bash -c './dist/release.sh &&
nix build --print-out-paths --print-build-logs .#dist && echo "rev=$FORTIFY_VERSION" >> $GITHUB_OUTPUT'
mv package.nix.old package.nix &&
echo "rev=$FORTIFY_REV" >> $GITHUB_OUTPUT
- name: Upload test build - name: Upload test build
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: "fortify-${{ steps.build-test.outputs.rev }}" name: "fortify-${{ steps.build-test.outputs.rev }}"
path: result/* path: dist/fortify-*
retention-days: 1 retention-days: 1

View File

@ -1,69 +0,0 @@
#include "acl-update.h"
#include <stdlib.h>
#include <stdbool.h>
#include <sys/acl.h>
#include <acl/libacl.h>
int f_acl_update_file_by_uid(const char *path_p, uid_t uid, acl_perm_t *perms, size_t plen) {
int ret = -1;
bool v;
int i;
acl_t acl;
acl_entry_t entry;
acl_tag_t tag_type;
void *qualifier_p;
acl_permset_t permset;
acl = acl_get_file(path_p, ACL_TYPE_ACCESS);
if (acl == NULL)
goto out;
// prune entries by uid
for (i = acl_get_entry(acl, ACL_FIRST_ENTRY, &entry); i == 1; i = acl_get_entry(acl, ACL_NEXT_ENTRY, &entry)) {
if (acl_get_tag_type(entry, &tag_type) != 0)
return -1;
if (tag_type != ACL_USER)
continue;
qualifier_p = acl_get_qualifier(entry);
if (qualifier_p == NULL)
return -1;
v = *(uid_t *)qualifier_p == uid;
acl_free(qualifier_p);
if (!v)
continue;
acl_delete_entry(acl, entry);
}
if (plen == 0)
goto set;
if (acl_create_entry(&acl, &entry) != 0)
goto out;
if (acl_get_permset(entry, &permset) != 0)
goto out;
for (i = 0; i < plen; i++) {
if (acl_add_perm(permset, perms[i]) != 0)
goto out;
}
if (acl_set_tag_type(entry, ACL_USER) != 0)
goto out;
if (acl_set_qualifier(entry, (void *)&uid) != 0)
goto out;
set:
if (acl_calc_mask(&acl) != 0)
goto out;
if (acl_valid(acl) != 0)
goto out;
if (acl_set_file(path_p, ACL_TYPE_ACCESS, acl) == 0)
ret = 0;
out:
free((void *)path_p);
if (acl != NULL)
acl_free((void *)acl);
return ret;
}

View File

@ -1,3 +0,0 @@
#include <sys/acl.h>
int f_acl_update_file_by_uid(const char *path_p, uid_t uid, acl_perm_t *perms, size_t plen);

View File

@ -1,36 +1,19 @@
// Package acl implements simple ACL manipulation via libacl. // Package acl implements simple ACL manipulation via libacl.
package acl package acl
/* type Perms []Perm
#cgo linux pkg-config: --static libacl
#include "acl-update.h" func (ps Perms) String() string {
*/ var s = []byte("---")
import "C" for _, p := range ps {
switch p {
type Perm C.acl_perm_t case Read:
s[0] = 'r'
const ( case Write:
Read Perm = C.ACL_READ s[1] = 'w'
Write Perm = C.ACL_WRITE case Execute:
Execute Perm = C.ACL_EXECUTE s[2] = 'x'
)
// Update replaces ACL_USER entry with qualifier uid.
func Update(name string, uid int, perms ...Perm) error {
var p *Perm
if len(perms) > 0 {
p = &perms[0]
} }
r, err := C.f_acl_update_file_by_uid(
C.CString(name),
C.uid_t(uid),
(*C.acl_perm_t)(p),
C.size_t(len(perms)),
)
if r == 0 {
return nil
} }
return err return string(s)
} }

View File

@ -47,7 +47,7 @@ func TestUpdatePerm(t *testing.T) {
}) })
t.Run("default clear mask", func(t *testing.T) { t.Run("default clear mask", func(t *testing.T) {
if err := acl.Update(testFilePath, uid); err != nil { if err := acl.UpdatePerm(testFilePath, uid); err != nil {
t.Fatalf("UpdatePerm: error = %v", err) t.Fatalf("UpdatePerm: error = %v", err)
} }
if cur = getfacl(t, testFilePath); len(cur) != 4 { if cur = getfacl(t, testFilePath); len(cur) != 4 {
@ -56,7 +56,7 @@ func TestUpdatePerm(t *testing.T) {
}) })
t.Run("default clear consistency", func(t *testing.T) { t.Run("default clear consistency", func(t *testing.T) {
if err := acl.Update(testFilePath, uid); err != nil { if err := acl.UpdatePerm(testFilePath, uid); err != nil {
t.Fatalf("UpdatePerm: error = %v", err) t.Fatalf("UpdatePerm: error = %v", err)
} }
if val := getfacl(t, testFilePath); !reflect.DeepEqual(val, cur) { if val := getfacl(t, testFilePath); !reflect.DeepEqual(val, cur) {
@ -76,7 +76,7 @@ func TestUpdatePerm(t *testing.T) {
func testUpdate(t *testing.T, testFilePath, name string, cur []*getFAclResp, val fAclPerm, perms ...acl.Perm) { func testUpdate(t *testing.T, testFilePath, name string, cur []*getFAclResp, val fAclPerm, perms ...acl.Perm) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Cleanup(func() { t.Cleanup(func() {
if err := acl.Update(testFilePath, uid); err != nil { if err := acl.UpdatePerm(testFilePath, uid); err != nil {
t.Fatalf("UpdatePerm: error = %v", err) t.Fatalf("UpdatePerm: error = %v", err)
} }
if v := getfacl(t, testFilePath); !reflect.DeepEqual(v, cur) { if v := getfacl(t, testFilePath); !reflect.DeepEqual(v, cur) {
@ -84,7 +84,7 @@ func testUpdate(t *testing.T, testFilePath, name string, cur []*getFAclResp, val
} }
}) })
if err := acl.Update(testFilePath, uid, perms...); err != nil { if err := acl.UpdatePerm(testFilePath, uid, perms...); err != nil {
t.Fatalf("UpdatePerm: error = %v", err) t.Fatalf("UpdatePerm: error = %v", err)
} }
r := respByCred(getfacl(t, testFilePath), fAclTypeUser, cred) r := respByCred(getfacl(t, testFilePath), fAclTypeUser, cred)

196
acl/c.go Normal file
View File

@ -0,0 +1,196 @@
package acl
import "C"
import (
"errors"
"runtime"
"syscall"
"unsafe"
)
/*
#cgo linux pkg-config: libacl
#include <stdlib.h>
#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"
func getFile(name string, t C.acl_type_t) (*ACL, error) {
a, err := C._go_acl_get_file(C.CString(name), t)
if errors.Is(err, syscall.ENODATA) {
err = nil
}
return newACL(a), err
}
func (acl *ACL) setFile(name string, t C.acl_type_t) error {
_, err := C._go_acl_set_file(C.CString(name), t, acl.acl)
return err
}
func newACL(a C.acl_t) *ACL {
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
// get first entry
if r, err := C.acl_get_entry(acl.acl, C.ACL_FIRST_ENTRY, &e); err != nil {
return err
} else if r == 0 {
// return on acl with no entries
return nil
}
for {
if r, err := C.acl_get_entry(acl.acl, C.ACL_NEXT_ENTRY, &e); err != nil {
return err
} else if r == 0 {
// return on drained acl
return nil
}
var (
q int
t C.acl_tag_t
)
// get current entry tag type
if _, err := C.acl_get_tag_type(e, &t); err != nil {
return err
}
// get current entry qualifier
if rq, err := C.acl_get_qualifier(e); err != nil {
// neither ACL_USER nor ACL_GROUP
if errors.Is(err, syscall.EINVAL) {
continue
}
return err
} else {
q = *(*int)(rq)
C.acl_free(rq)
}
// delete on match
if t == tt && q == tq {
_, err := C.acl_delete_entry(acl.acl, e)
return err
}
}
}
func UpdatePerm(name string, uid int, perms ...Perm) error {
// read acl from file
a, err := getFile(name, 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.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)
}

View File

@ -1,18 +0,0 @@
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

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

13
cmd/finit/ipc/payload.go Normal file
View File

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

174
cmd/finit/main.go Normal file
View File

@ -0,0 +1,174 @@
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,154 +0,0 @@
package main
import (
"encoding/json"
"log"
"os"
"path"
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
"git.gensokyo.uk/security/fortify/system"
)
type appInfo struct {
Name string `json:"name"`
Version string `json:"version"`
// passed through to [fst.Config]
ID string `json:"id"`
// passed through to [fst.Config]
Identity int `json:"identity"`
// passed through to [fst.Config]
Groups []string `json:"groups,omitempty"`
// passed through to [fst.Config]
Devel bool `json:"devel,omitempty"`
// passed through to [fst.Config]
Userns bool `json:"userns,omitempty"`
// passed through to [fst.Config]
Net bool `json:"net,omitempty"`
// passed through to [fst.Config]
Device bool `json:"dev,omitempty"`
// passed through to [fst.Config]
Tty bool `json:"tty,omitempty"`
// passed through to [fst.Config]
MapRealUID bool `json:"map_real_uid,omitempty"`
// passed through to [fst.Config]
DirectWayland bool `json:"direct_wayland,omitempty"`
// passed through to [fst.Config]
SystemBus *dbus.Config `json:"system_bus,omitempty"`
// passed through to [fst.Config]
SessionBus *dbus.Config `json:"session_bus,omitempty"`
// passed through to [fst.Config]
Enablements system.Enablement `json:"enablements"`
// passed through to [fst.Config]
Multiarch bool `json:"multiarch,omitempty"`
// passed through to [fst.Config]
Bluetooth bool `json:"bluetooth,omitempty"`
// allow gpu access within sandbox
GPU bool `json:"gpu"`
// store path to nixGL mesa wrappers
Mesa string `json:"mesa,omitempty"`
// store path to nixGL source
NixGL string `json:"nix_gl,omitempty"`
// store path to activate-and-exec script
Launcher string `json:"launcher"`
// store path to /run/current-system
CurrentSystem string `json:"current_system"`
// store path to home-manager activation package
ActivationPackage string `json:"activation_package"`
}
func (app *appInfo) toFst(pathSet *appPathSet, argv []string, flagDropShell bool) *fst.Config {
config := &fst.Config{
ID: app.ID,
Path: argv[0],
Args: argv,
Enablements: app.Enablements,
SystemBus: app.SystemBus,
SessionBus: app.SessionBus,
DirectWayland: app.DirectWayland,
Username: "fortify",
Shell: shellPath,
Data: pathSet.homeDir,
Dir: path.Join("/data/data", app.ID),
Identity: app.Identity,
Groups: app.Groups,
Container: &fst.ContainerConfig{
Hostname: formatHostname(app.Name),
Devel: app.Devel,
Userns: app.Userns,
Net: app.Net,
Device: app.Device,
Tty: app.Tty || flagDropShell,
MapRealUID: app.MapRealUID,
Filesystem: []*fst.FilesystemConfig{
{Src: path.Join(pathSet.nixPath, "store"), Dst: "/nix/store", Must: true},
{Src: pathSet.metaPath, Dst: path.Join(fst.Tmp, "app"), Must: true},
{Src: "/etc/resolv.conf"},
{Src: "/sys/block"},
{Src: "/sys/bus"},
{Src: "/sys/class"},
{Src: "/sys/dev"},
{Src: "/sys/devices"},
},
Link: [][2]string{
{app.CurrentSystem, "/run/current-system"},
{"/run/current-system/sw/bin", "/bin"},
{"/run/current-system/sw/bin", "/usr/bin"},
},
Etc: path.Join(pathSet.cacheDir, "etc"),
AutoEtc: true,
},
ExtraPerms: []*fst.ExtraPermConfig{
{Path: dataHome, Execute: true},
{Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true},
},
}
if app.Multiarch {
config.Container.Seccomp |= seccomp.FilterMultiarch
}
if app.Bluetooth {
config.Container.Seccomp |= seccomp.FilterBluetooth
}
return config
}
func loadAppInfo(name string, beforeFail func()) *appInfo {
bundle := new(appInfo)
if f, err := os.Open(name); err != nil {
beforeFail()
log.Fatalf("cannot open bundle: %v", err)
} else if err = json.NewDecoder(f).Decode(&bundle); err != nil {
beforeFail()
log.Fatalf("cannot parse bundle metadata: %v", err)
} else if err = f.Close(); err != nil {
log.Printf("cannot close bundle metadata: %v", err)
// not fatal
}
if bundle.ID == "" {
beforeFail()
log.Fatal("application identifier must not be empty")
}
return bundle
}
func formatHostname(name string) string {
if h, err := os.Hostname(); err != nil {
log.Printf("cannot get hostname: %v", err)
return "fortify-" + name
} else {
return h + "-" + name
}
}

83
cmd/fpkg/bundle.go Normal file
View File

@ -0,0 +1,83 @@
package main
import (
"encoding/json"
"os"
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/system"
)
type bundleInfo struct {
Name string `json:"name"`
Version string `json:"version"`
// passed through to [fst.Config]
ID string `json:"id"`
// passed through to [fst.Config]
AppID int `json:"app_id"`
// passed through to [fst.Config]
Groups []string `json:"groups,omitempty"`
// passed through to [fst.Config]
UserNS bool `json:"userns,omitempty"`
// passed through to [fst.Config]
Net bool `json:"net,omitempty"`
// passed through to [fst.Config]
Dev bool `json:"dev,omitempty"`
// passed through to [fst.Config]
NoNewSession bool `json:"no_new_session,omitempty"`
// passed through to [fst.Config]
MapRealUID bool `json:"map_real_uid,omitempty"`
// passed through to [fst.Config]
DirectWayland bool `json:"direct_wayland,omitempty"`
// passed through to [fst.Config]
SystemBus *dbus.Config `json:"system_bus,omitempty"`
// passed through to [fst.Config]
SessionBus *dbus.Config `json:"session_bus,omitempty"`
// passed through to [fst.Config]
Enablements system.Enablements `json:"enablements"`
// allow gpu access within sandbox
GPU bool `json:"gpu"`
// store path to nixGL mesa wrappers
Mesa string `json:"mesa,omitempty"`
// store path to nixGL source
NixGL string `json:"nix_gl,omitempty"`
// store path to activate-and-exec script
Launcher string `json:"launcher"`
// store path to /run/current-system
CurrentSystem string `json:"current_system"`
// store path to home-manager activation package
ActivationPackage string `json:"activation_package"`
}
func loadBundleInfo(name string, beforeFail func()) *bundleInfo {
bundle := new(bundleInfo)
if f, err := os.Open(name); err != nil {
beforeFail()
fmsg.Fatalf("cannot open bundle: %v", err)
} else if err = json.NewDecoder(f).Decode(&bundle); err != nil {
beforeFail()
fmsg.Fatalf("cannot parse bundle metadata: %v", err)
} else if err = f.Close(); err != nil {
fmsg.Printf("cannot close bundle metadata: %v", err)
// not fatal
}
if bundle.ID == "" {
beforeFail()
fmsg.Fatal("application identifier must not be empty")
}
return bundle
}
func formatHostname(name string) string {
if h, err := os.Hostname(); err != nil {
fmsg.Printf("cannot get hostname: %v", err)
return "fortify-" + name
} else {
return h + "-" + name
}
}

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

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

View File

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

View File

@ -1,14 +1,12 @@
package main package main
import ( import (
"log"
"os" "os"
"os/exec" "os/exec"
"path" "path"
"strconv" "strconv"
"sync/atomic" "sync/atomic"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/fmsg" "git.gensokyo.uk/security/fortify/internal/fmsg"
) )
@ -27,8 +25,8 @@ func init() {
func lookPath(file string) string { func lookPath(file string) string {
if p, err := exec.LookPath(file); err != nil { if p, err := exec.LookPath(file); err != nil {
log.Fatalf("%s: command not found", file) fmsg.Fatalf("%s: command not found", file)
return "" panic("unreachable")
} else { } else {
return p return p
} }
@ -37,14 +35,15 @@ func lookPath(file string) string {
var beforeRunFail = new(atomic.Pointer[func()]) var beforeRunFail = new(atomic.Pointer[func()])
func mustRun(name string, arg ...string) { func mustRun(name string, arg ...string) {
fmsg.Verbosef("spawning process: %q %q", name, arg) fmsg.VPrintf("spawning process: %q %q", name, arg)
cmd := exec.Command(name, arg...) cmd := exec.Command(name, arg...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
if f := beforeRunFail.Swap(nil); f != nil { if f := beforeRunFail.Swap(nil); f != nil {
(*f)() (*f)()
} }
log.Fatalf("%s: %v", name, err) fmsg.Fatalf("%s: %v", name, err)
panic("unreachable")
} }
} }
@ -70,32 +69,3 @@ func pathSetByApp(id string) *appPathSet {
pathSet.nixPath = path.Join(pathSet.cacheDir, "nix") pathSet.nixPath = path.Join(pathSet.cacheDir, "nix")
return pathSet return pathSet
} }
func appendGPUFilesystem(config *fst.Config) {
config.Container.Filesystem = append(config.Container.Filesystem, []*fst.FilesystemConfig{
// flatpak commit 763a686d874dd668f0236f911de00b80766ffe79
{Src: "/dev/dri", Device: true},
// mali
{Src: "/dev/mali", Device: true},
{Src: "/dev/mali0", Device: true},
{Src: "/dev/umplock", Device: true},
// nvidia
{Src: "/dev/nvidiactl", Device: true},
{Src: "/dev/nvidia-modeset", Device: true},
// nvidia OpenCL/CUDA
{Src: "/dev/nvidia-uvm", Device: true},
{Src: "/dev/nvidia-uvm-tools", Device: true},
// flatpak commit d2dff2875bb3b7e2cd92d8204088d743fd07f3ff
{Src: "/dev/nvidia0", Device: true}, {Src: "/dev/nvidia1", Device: true},
{Src: "/dev/nvidia2", Device: true}, {Src: "/dev/nvidia3", Device: true},
{Src: "/dev/nvidia4", Device: true}, {Src: "/dev/nvidia5", Device: true},
{Src: "/dev/nvidia6", Device: true}, {Src: "/dev/nvidia7", Device: true},
{Src: "/dev/nvidia8", Device: true}, {Src: "/dev/nvidia9", Device: true},
{Src: "/dev/nvidia10", Device: true}, {Src: "/dev/nvidia11", Device: true},
{Src: "/dev/nvidia12", Device: true}, {Src: "/dev/nvidia13", Device: true},
{Src: "/dev/nvidia14", Device: true}, {Src: "/dev/nvidia15", Device: true},
{Src: "/dev/nvidia16", Device: true}, {Src: "/dev/nvidia17", Device: true},
{Src: "/dev/nvidia18", Device: true}, {Src: "/dev/nvidia19", Device: true},
}...)
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,25 +1,20 @@
package main package main
import ( import (
"context"
"path" "path"
"strings" "strings"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal" "git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
) )
func withNixDaemon( func withNixDaemon(
ctx context.Context,
action string, command []string, net bool, updateConfig func(config *fst.Config) *fst.Config, action string, command []string, net bool, updateConfig func(config *fst.Config) *fst.Config,
app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func(), app *bundleInfo, pathSet *appPathSet, dropShell bool, beforeFail func(),
) { ) {
mustRunAppDropShell(ctx, updateConfig(&fst.Config{ fortifyAppDropShell(updateConfig(&fst.Config{
ID: app.ID, ID: app.ID,
Command: []string{shell, "-lc", "rm -f /nix/var/nix/daemon-socket/socket && " +
Path: shellPath,
Args: []string{shellPath, "-lc", "rm -f /nix/var/nix/daemon-socket/socket && " +
// start nix-daemon // start nix-daemon
"nix-daemon --store / & " + "nix-daemon --store / & " +
// wait for socket to appear // wait for socket to appear
@ -30,24 +25,16 @@ func withNixDaemon(
// terminate nix-daemon // terminate nix-daemon
" && pkill nix-daemon", " && pkill nix-daemon",
}, },
Confinement: fst.ConfinementConfig{
AppID: app.AppID,
Username: "fortify", Username: "fortify",
Shell: shellPath, Inner: path.Join("/data/data", app.ID),
Data: pathSet.homeDir, Outer: pathSet.homeDir,
Dir: path.Join("/data/data", app.ID), Sandbox: &fst.SandboxConfig{
ExtraPerms: []*fst.ExtraPermConfig{
{Path: dataHome, Execute: true},
{Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true},
},
Identity: app.Identity,
Container: &fst.ContainerConfig{
Hostname: formatHostname(app.Name) + "-" + action, Hostname: formatHostname(app.Name) + "-" + action,
Userns: true, // nix sandbox requires userns UserNS: true, // nix sandbox requires userns
Net: net, Net: net,
Seccomp: seccomp.FilterMultiarch, NoNewSession: dropShell,
Tty: dropShell,
Filesystem: []*fst.FilesystemConfig{ Filesystem: []*fst.FilesystemConfig{
{Src: pathSet.nixPath, Dst: "/nix", Write: true, Must: true}, {Src: pathSet.nixPath, Dst: "/nix", Write: true, Must: true},
}, },
@ -59,35 +46,26 @@ func withNixDaemon(
Etc: path.Join(pathSet.cacheDir, "etc"), Etc: path.Join(pathSet.cacheDir, "etc"),
AutoEtc: true, AutoEtc: true,
}, },
}), dropShell, beforeFail)
}
func withCacheDir(
ctx context.Context,
action string, command []string, workDir string,
app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func()) {
mustRunAppDropShell(ctx, &fst.Config{
ID: app.ID,
Path: shellPath,
Args: []string{shellPath, "-lc", strings.Join(command, " && ")},
Username: "nixos",
Shell: shellPath,
Data: pathSet.cacheDir, // this also ensures cacheDir via shim
Dir: path.Join("/data/data", app.ID, "cache"),
ExtraPerms: []*fst.ExtraPermConfig{ ExtraPerms: []*fst.ExtraPermConfig{
{Path: dataHome, Execute: true}, {Path: dataHome, Execute: true},
{Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true}, {Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true},
{Path: workDir, Execute: true},
}, },
},
}), dropShell, beforeFail)
}
Identity: app.Identity, func withCacheDir(action string, command []string, workDir string, app *bundleInfo, pathSet *appPathSet, dropShell bool, beforeFail func()) {
fortifyAppDropShell(&fst.Config{
Container: &fst.ContainerConfig{ ID: app.ID,
Command: []string{shell, "-lc", strings.Join(command, " && ")},
Confinement: fst.ConfinementConfig{
AppID: app.AppID,
Username: "nixos",
Inner: path.Join("/data/data", app.ID, "cache"),
Outer: pathSet.cacheDir, // this also ensures cacheDir via fshim
Sandbox: &fst.SandboxConfig{
Hostname: formatHostname(app.Name) + "-" + action, Hostname: formatHostname(app.Name) + "-" + action,
Seccomp: seccomp.FilterMultiarch, NoNewSession: dropShell,
Tty: dropShell,
Filesystem: []*fst.FilesystemConfig{ Filesystem: []*fst.FilesystemConfig{
{Src: path.Join(workDir, "nix"), Dst: "/nix", Must: true}, {Src: path.Join(workDir, "nix"), Dst: "/nix", Must: true},
{Src: workDir, Dst: path.Join(fst.Tmp, "bundle"), Must: true}, {Src: workDir, Dst: path.Join(fst.Tmp, "bundle"), Must: true},
@ -100,15 +78,21 @@ func withCacheDir(
Etc: path.Join(workDir, "etc"), Etc: path.Join(workDir, "etc"),
AutoEtc: true, AutoEtc: true,
}, },
ExtraPerms: []*fst.ExtraPermConfig{
{Path: dataHome, Execute: true},
{Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true},
{Path: workDir, Execute: true},
},
},
}, dropShell, beforeFail) }, dropShell, beforeFail)
} }
func mustRunAppDropShell(ctx context.Context, config *fst.Config, dropShell bool, beforeFail func()) { func fortifyAppDropShell(config *fst.Config, dropShell bool, beforeFail func()) {
if dropShell { if dropShell {
config.Args = []string{shellPath, "-l"} config.Command = []string{shell, "-l"}
mustRunApp(ctx, config, beforeFail) fortifyApp(config, beforeFail)
beforeFail() beforeFail()
internal.Exit(0) fmsg.Exit(0)
} }
mustRunApp(ctx, config, beforeFail) fortifyApp(config, beforeFail)
} }

23
cmd/fshim/ipc/payload.go Normal file
View File

@ -0,0 +1,23 @@
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
// path to outer home directory
Home string
// sync fd
Sync *uintptr
// verbosity pass through
Verbose bool
}

137
cmd/fshim/ipc/shim/shim.go Normal file
View File

@ -0,0 +1,137 @@
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
}

165
cmd/fshim/main.go Normal file
View File

@ -0,0 +1,165 @@
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/fst"
"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
}
// ensure home directory as target user
if s, err := os.Stat(payload.Home); err != nil {
if os.IsNotExist(err) {
if err = os.Mkdir(payload.Home, 0700); err != nil {
fmsg.Fatalf("cannot create home directory: %v", err)
}
} else {
fmsg.Fatalf("cannot access home directory: %v", err)
}
// home directory is created, proceed
} else if !s.IsDir() {
fmsg.Fatalf("data path %q is not a directory", payload.Home)
}
var ic init0.Payload
// resolve argv0
ic.Argv = payload.Argv
if len(ic.Argv) > 0 {
// looked up from $PATH by parent
ic.Argv0 = payload.Exec[1]
} else {
// no argv, look up shell instead
var ok bool
if payload.Bwrap.SetEnv == nil {
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)
}
}()
}
// bind finit inside sandbox
finitInnerPath := path.Join(fst.Tmp, "sbin", "init")
conf.Bind(finitPath, finitInnerPath)
helper.BubblewrapName = payload.Exec[0] // resolved bwrap path by parent
if b, err := helper.NewBwrap(conf, nil, finitInnerPath,
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

@ -13,6 +13,7 @@ import (
) )
const ( const (
compPoison = "INVALIDINVALIDINVALIDINVALIDINVALID"
fsuConfFile = "/etc/fsurc" fsuConfFile = "/etc/fsurc"
envShim = "FORTIFY_SHIM" envShim = "FORTIFY_SHIM"
envAID = "FORTIFY_APP_ID" envAID = "FORTIFY_APP_ID"
@ -21,6 +22,11 @@ const (
PR_SET_NO_NEW_PRIVS = 0x26 PR_SET_NO_NEW_PRIVS = 0x26
) )
var (
Fmain = compPoison
Fshim = compPoison
)
func main() { func main() {
log.SetFlags(0) log.SetFlags(0)
log.SetPrefix("fsu: ") log.SetPrefix("fsu: ")
@ -35,16 +41,25 @@ func main() {
log.Fatal("this program must not be started by root") log.Fatal("this program must not be started by root")
} }
var toolPath string var fmain, 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") pexe := path.Join("/proc", strconv.Itoa(os.Getppid()), "exe")
if p, err := os.Readlink(pexe); err != nil { if p, err := os.Readlink(pexe); err != nil {
log.Fatalf("cannot read parent executable path: %v", err) log.Fatalf("cannot read parent executable path: %v", err)
} else if strings.HasSuffix(p, " (deleted)") { } else if strings.HasSuffix(p, " (deleted)") {
log.Fatal("fortify executable has been deleted") log.Fatal("fortify executable has been deleted")
} else if p != mustCheckPath(fmain) && p != mustCheckPath(fpkg) { } else if p != fmain {
log.Fatal("this program must be started by fortify") log.Fatal("this program must be started by fortify")
} else {
toolPath = p
} }
// uid = 1000000 + // uid = 1000000 +
@ -52,19 +67,8 @@ func main() {
// aid // aid
uid := 1000000 uid := 1000000
// refuse to run if fsurc is not protected correctly
if s, err := os.Stat(fsuConfFile); 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")
}
// authenticate before accepting user input // authenticate before accepting user input
if f, err := os.Open(fsuConfFile); err != nil { if fid, ok := parseConfig(fsuConfFile, puid); !ok {
log.Fatal(err)
} else if fid, ok := mustParseConfig(f, puid); !ok {
log.Fatalf("uid %d is not in the fsurc file", puid) log.Fatalf("uid %d is not in the fsurc file", puid)
} else { } else {
uid += fid * 10000 uid += fid * 10000
@ -138,9 +142,13 @@ func main() {
if _, _, errno := syscall.AllThreadsSyscall(syscall.SYS_PRCTL, PR_SET_NO_NEW_PRIVS, 1, 0); errno != 0 { if _, _, errno := syscall.AllThreadsSyscall(syscall.SYS_PRCTL, PR_SET_NO_NEW_PRIVS, 1, 0); errno != 0 {
log.Fatalf("cannot set no_new_privs flag: %s", errno.Error()) log.Fatalf("cannot set no_new_privs flag: %s", errno.Error())
} }
if err := syscall.Exec(toolPath, []string{"fortify", "shim"}, []string{envShim + "=" + shimSetupFd}); err != nil { if err := syscall.Exec(fshim, []string{"fshim"}, []string{envShim + "=" + shimSetupFd}); err != nil {
log.Fatalf("cannot start shim: %v", err) log.Fatalf("cannot start shim: %v", err)
} }
panic("unreachable") panic("unreachable")
} }
func checkPath(p string) (string, bool) {
return p, p != compPoison && p != "" && path.IsAbs(p)
}

View File

@ -1,30 +0,0 @@
{
lib,
buildGoModule,
fortify ? abort "fortify package required",
}:
buildGoModule {
pname = "${fortify.pname}-fsu";
inherit (fortify) version;
src = ./.;
inherit (fortify) vendorHash;
CGO_ENABLED = 0;
preBuild = ''
go mod init fsu >& /dev/null
'';
ldflags =
lib.attrsets.foldlAttrs
(
ldflags: name: value:
ldflags ++ [ "-X main.${name}=${value}" ]
)
[ "-s -w" ]
{
fmain = "${fortify}/libexec/fortify";
fpkg = "${fortify}/libexec/fpkg";
};
}

View File

@ -4,9 +4,10 @@ import (
"bufio" "bufio"
"errors" "errors"
"fmt" "fmt"
"io"
"log" "log"
"os"
"strings" "strings"
"syscall"
) )
func parseUint32Fast(s string) (int, error) { func parseUint32Fast(s string) (int, error) {
@ -22,46 +23,55 @@ func parseUint32Fast(s string) (int, error) {
for i, ch := range []byte(s) { for i, ch := range []byte(s) {
ch -= '0' ch -= '0'
if ch > 9 { if ch > 9 {
return -1, fmt.Errorf("invalid character '%s' at index %d", string(ch+'0'), i) return -1, fmt.Errorf("invalid character '%s' at index %d", string([]byte{ch}), i)
} }
n = n*10 + int(ch) n = n*10 + int(ch)
} }
return n, nil return n, nil
} }
func parseConfig(r io.Reader, puid int) (fid int, ok bool, err error) { 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) s := bufio.NewScanner(r)
var line, puid0 int var line int
for s.Scan() { for s.Scan() {
line++ line++
// <puid> <fid> // <puid> <fid>
lf := strings.SplitN(s.Text(), " ", 2) lf := strings.SplitN(s.Text(), " ", 2)
if len(lf) != 2 { if len(lf) != 2 {
return -1, false, fmt.Errorf("invalid entry on line %d", line) log.Fatalf("invalid entry on line %d", line)
} }
puid0, err = parseUint32Fast(lf[0]) var puid0 int
if err != nil || puid0 < 1 { if puid0, err = parseUint32Fast(lf[0]); err != nil || puid0 < 1 {
return -1, false, fmt.Errorf("invalid parent uid on line %d", line) log.Fatalf("invalid parent uid on line %d", line)
} }
ok = puid0 == puid ok = puid0 == puid
if ok { if ok {
// allowed fid range 0 to 99 // allowed fid range 0 to 99
if fid, err = parseUint32Fast(lf[1]); err != nil || fid < 0 || fid > 99 { if fid, err = parseUint32Fast(lf[1]); err != nil || fid < 0 || fid > 99 {
return -1, false, fmt.Errorf("invalid fortify uid on line %d", line) log.Fatalf("invalid fortify uid on line %d", line)
} }
return return
} }
} }
return -1, false, s.Err() if err = s.Err(); err != nil {
} log.Fatalf("cannot read fsurc: %v", err)
}
func mustParseConfig(r io.Reader, puid int) (int, bool) { return -1, false
fid, ok, err := parseConfig(r, puid)
if err != nil {
log.Fatal(err)
} }
return fid, ok
} }

View File

@ -1,96 +0,0 @@
package main
import (
"bytes"
"strconv"
"testing"
)
func Test_parseUint32Fast(t *testing.T) {
t.Run("zero-length", func(t *testing.T) {
if _, err := parseUint32Fast(""); err == nil || err.Error() != "zero length string" {
t.Errorf(`parseUint32Fast(""): error = %v`, err)
return
}
})
t.Run("overflow", func(t *testing.T) {
if _, err := parseUint32Fast("10000000000"); err == nil || err.Error() != "string too long" {
t.Errorf("parseUint32Fast: error = %v", err)
return
}
})
t.Run("invalid byte", func(t *testing.T) {
if _, err := parseUint32Fast("meow"); err == nil || err.Error() != "invalid character 'm' at index 0" {
t.Errorf(`parseUint32Fast("meow"): error = %v`, err)
return
}
})
t.Run("full range", func(t *testing.T) {
testRange := func(i, end int) {
for ; i < end; i++ {
s := strconv.Itoa(i)
w := i
t.Run("parse "+s, func(t *testing.T) {
t.Parallel()
v, err := parseUint32Fast(s)
if err != nil {
t.Errorf("parseUint32Fast(%q): error = %v",
s, err)
return
}
if v != w {
t.Errorf("parseUint32Fast(%q): got %v",
s, v)
return
}
})
}
}
testRange(0, 5000)
testRange(105000, 110000)
testRange(23005000, 23010000)
testRange(456005000, 456010000)
testRange(7890005000, 7890010000)
})
}
func Test_parseConfig(t *testing.T) {
testCases := []struct {
name string
puid, want int
wantErr string
rc string
}{
{"empty", 0, -1, "", ``},
{"invalid field", 0, -1, "invalid entry on line 1", `9`},
{"invalid puid", 0, -1, "invalid parent uid on line 1", `f 9`},
{"invalid fid", 1000, -1, "invalid fortify uid on line 1", `1000 f`},
{"match", 1000, 0, "", `1000 0`},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fid, ok, err := parseConfig(bytes.NewBufferString(tc.rc), tc.puid)
if err == nil && tc.wantErr != "" {
t.Errorf("parseConfig: error = %v; wantErr %q",
err, tc.wantErr)
return
}
if err != nil && err.Error() != tc.wantErr {
t.Errorf("parseConfig: error = %q; wantErr %q",
err, tc.wantErr)
return
}
if ok == (tc.want == -1) {
t.Errorf("parseConfig: ok = %v; want %v",
ok, tc.want)
return
}
if fid != tc.want {
t.Errorf("parseConfig: fid = %v; want %v",
fid, tc.want)
}
})
}
}

View File

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

69
cmd/fuserdb/main.go Normal file
View File

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

64
cmd/fuserdb/payload.go Normal file
View File

@ -0,0 +1,64 @@
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,65 +0,0 @@
package command
import (
"flag"
"fmt"
"io"
)
// New initialises a root Node.
func New(output io.Writer, logf LogFunc, name string, early HandlerFunc) Command {
c := rootNode{newNode(output, logf, name, "")}
c.f = early
return c
}
func newNode(output io.Writer, logf LogFunc, name, usage string) *node {
n := &node{
name: name, usage: usage,
out: output, logf: logf,
set: flag.NewFlagSet(name, flag.ContinueOnError),
}
n.set.SetOutput(output)
n.set.Usage = func() {
_ = n.writeHelp()
if n.suffix.Len() > 0 {
_, _ = fmt.Fprintln(output, "Flags:")
n.set.PrintDefaults()
_, _ = fmt.Fprintln(output)
}
}
return n
}
func (n *node) Command(name, usage string, f HandlerFunc) Node {
n.NewCommand(name, usage, f)
return n
}
func (n *node) NewCommand(name, usage string, f HandlerFunc) Flag[Node] {
if f == nil {
panic("invalid handler")
}
if name == "" || usage == "" {
panic("invalid subcommand")
}
s := newNode(n.out, n.logf, name, usage)
s.f = f
if !n.adopt(s) {
panic("attempted to initialise subcommand with non-unique name")
}
return s
}
func (n *node) New(name, usage string) Node {
if name == "" || usage == "" {
panic("invalid subcommand tree")
}
s := newNode(n.out, n.logf, name, usage)
if !n.adopt(s) {
panic("attempted to initialise subcommand tree with non-unique name")
}
return s
}

View File

@ -1,56 +0,0 @@
package command_test
import (
"testing"
"git.gensokyo.uk/security/fortify/command"
)
func TestBuild(t *testing.T) {
c := command.New(nil, nil, "test", nil)
stubHandler := func([]string) error { panic("unreachable") }
t.Run("nil direct handler", func(t *testing.T) {
defer checkRecover(t, "Command", "invalid handler")
c.Command("name", "usage", nil)
})
t.Run("direct zero length", func(t *testing.T) {
wantPanic := "invalid subcommand"
t.Run("zero length name", func(t *testing.T) { defer checkRecover(t, "Command", wantPanic); c.Command("", "usage", stubHandler) })
t.Run("zero length usage", func(t *testing.T) { defer checkRecover(t, "Command", wantPanic); c.Command("name", "", stubHandler) })
})
t.Run("direct adopt unique names", func(t *testing.T) {
c.Command("d0", "usage", stubHandler)
c.Command("d1", "usage", stubHandler)
})
t.Run("direct adopt non-unique name", func(t *testing.T) {
defer checkRecover(t, "Command", "attempted to initialise subcommand with non-unique name")
c.Command("d0", "usage", stubHandler)
})
t.Run("zero length", func(t *testing.T) {
wantPanic := "invalid subcommand tree"
t.Run("zero length name", func(t *testing.T) { defer checkRecover(t, "New", wantPanic); c.New("", "usage") })
t.Run("zero length usage", func(t *testing.T) { defer checkRecover(t, "New", wantPanic); c.New("name", "") })
})
t.Run("direct adopt unique names", func(t *testing.T) {
c.New("t0", "usage")
c.New("t1", "usage")
})
t.Run("direct adopt non-unique name", func(t *testing.T) {
defer checkRecover(t, "Command", "attempted to initialise subcommand tree with non-unique name")
c.New("t0", "usage")
})
}
func checkRecover(t *testing.T, name, wantPanic string) {
if r := recover(); r != wantPanic {
t.Errorf("%s: panic = %v; wantPanic %v",
name, r, wantPanic)
}
}

View File

@ -1,55 +0,0 @@
// Package command implements generic nested command parsing.
package command
import (
"flag"
"strings"
)
// UsageInternal causes the command to be hidden from help text when set as the usage string.
const UsageInternal = "internal"
type (
// HandlerFunc is called when matching a directly handled subcommand tree.
HandlerFunc = func(args []string) error
// LogFunc is the function signature of a printf function.
LogFunc = func(format string, a ...any)
// FlagDefiner is a deferred flag definer value, usually encapsulating the default value.
FlagDefiner interface {
// Define defines the flag in set.
Define(b *strings.Builder, set *flag.FlagSet, p any, name, usage string)
}
Flag[T any] interface {
// Flag defines a generic flag type in Node's flag set.
Flag(p any, name string, value FlagDefiner, usage string) T
}
Command interface {
Parse(arguments []string) error
// MustParse determines exit outcomes for Parse errors
// and calls handleError if [HandlerFunc] returns a non-nil error.
MustParse(arguments []string, handleError func(error))
baseNode[Command]
}
Node baseNode[Node]
baseNode[T any] interface {
// Command appends a subcommand with direct command handling.
Command(name, usage string, f HandlerFunc) T
// New returns a new subcommand tree.
New(name, usage string) (sub Node)
// NewCommand returns a new subcommand with direct command handling.
NewCommand(name, usage string, f HandlerFunc) (sub Flag[Node])
// PrintHelp prints a help message to the configured writer.
PrintHelp()
Flag[T]
}
)

View File

@ -1,77 +0,0 @@
package command
import (
"errors"
"flag"
"strings"
)
// FlagError wraps errors returned by [flag].
type FlagError struct{ error }
func (e FlagError) Success() bool { return errors.Is(e.error, flag.ErrHelp) }
func (e FlagError) Is(target error) bool {
return (e.error == nil && target == nil) ||
((e.error != nil && target != nil) && e.error.Error() == target.Error())
}
func (n *node) Flag(p any, name string, value FlagDefiner, usage string) Node {
value.Define(&n.suffix, n.set, p, name, usage)
return n
}
// StringFlag is the default value of a string flag.
type StringFlag string
func (v StringFlag) Define(b *strings.Builder, set *flag.FlagSet, p any, name, usage string) {
set.StringVar(p.(*string), name, string(v), usage)
b.WriteString(" [" + prettyFlag(name) + " <value>]")
}
// IntFlag is the default value of an int flag.
type IntFlag int
func (v IntFlag) Define(b *strings.Builder, set *flag.FlagSet, p any, name, usage string) {
set.IntVar(p.(*int), name, int(v), usage)
b.WriteString(" [" + prettyFlag(name) + " <int>]")
}
// BoolFlag is the default value of a bool flag.
type BoolFlag bool
func (v BoolFlag) Define(b *strings.Builder, set *flag.FlagSet, p any, name, usage string) {
set.BoolVar(p.(*bool), name, bool(v), usage)
b.WriteString(" [" + prettyFlag(name) + "]")
}
// RepeatableFlag implements an ordered, repeatable string flag.
type RepeatableFlag []string
func (r *RepeatableFlag) String() string {
if r == nil {
return "<nil>"
}
return strings.Join(*r, " ")
}
func (r *RepeatableFlag) Set(v string) error {
*r = append(*r, v)
return nil
}
func (r *RepeatableFlag) Define(b *strings.Builder, set *flag.FlagSet, _ any, name, usage string) {
set.Var(r, name, usage)
b.WriteString(" [" + prettyFlag(name) + " <value>]")
}
// this has no effect on parse outcome
func prettyFlag(name string) string {
switch len(name) {
case 0:
panic("zero length flag name")
case 1:
return "-" + name
default:
return "--" + name
}
}

View File

@ -1,53 +0,0 @@
package command
import (
"errors"
"fmt"
"io"
"strings"
"text/tabwriter"
)
var ErrHelp = errors.New("help requested")
func (n *node) PrintHelp() { _ = n.writeHelp() }
func (n *node) writeHelp() error {
if _, err := fmt.Fprintf(n.out,
"\nUsage:\t%s [-h | --help]%s COMMAND [OPTIONS]\n",
strings.Join(append(n.prefix, n.name), " "), &n.suffix,
); err != nil {
return err
}
if n.child != nil {
if _, err := fmt.Fprint(n.out, "\nCommands:\n"); err != nil {
return err
}
}
tw := tabwriter.NewWriter(n.out, 0, 1, 4, ' ', 0)
if err := n.child.writeCommands(tw); err != nil {
return err
}
if err := tw.Flush(); err != nil {
return err
}
_, err := n.out.Write([]byte{'\n'})
if err == nil {
err = ErrHelp
}
return err
}
func (n *node) writeCommands(w io.Writer) error {
if n == nil {
return nil
}
if n.usage != UsageInternal {
if _, err := fmt.Fprintf(w, "\t%s\t%s\n", n.name, n.usage); err != nil {
return err
}
}
return n.next.writeCommands(w)
}

View File

@ -1,40 +0,0 @@
package command
import (
"flag"
"io"
"strings"
)
type node struct {
child, next *node
name, usage string
out io.Writer
logf LogFunc
prefix []string
suffix strings.Builder
f HandlerFunc
set *flag.FlagSet
}
func (n *node) adopt(v *node) bool {
if n.child != nil {
return n.child.append(v)
}
n.child = v
return true
}
func (n *node) append(v *node) bool {
if n.name == v.name {
return false
}
if n.next != nil {
return n.next.append(v)
}
n.next = v
return true
}

View File

@ -1,105 +0,0 @@
package command
import (
"errors"
"log"
"os"
)
var (
ErrEmptyTree = errors.New("subcommand tree has no nodes")
ErrNoMatch = errors.New("did not match any subcommand")
)
func (n *node) Parse(arguments []string) error {
if n.usage == "" { // root node has zero length usage
if n.next != nil {
panic("invalid toplevel state")
}
goto match
}
if len(arguments) == 0 {
// unreachable: zero length args cause upper level to return with a help message
panic("attempted to parse with zero length args")
}
if arguments[0] != n.name {
if n.next == nil {
n.printf("%q is not a valid command", arguments[0])
return ErrNoMatch
}
n.next.prefix = n.prefix
return n.next.Parse(arguments)
}
arguments = arguments[1:]
match:
if n.child != nil {
// propagate help prefix early: flag set usage dereferences help
n.child.prefix = append(n.prefix, n.name)
}
if n.set.Parsed() {
panic("invalid set state")
}
if err := n.set.Parse(arguments); err != nil {
return FlagError{err}
}
args := n.set.Args()
if n.child != nil {
if n.f != nil {
if n.usage != "" { // root node early special case
panic("invalid subcommand tree state")
}
// special case: root node calls HandlerFunc for initialisation
if err := n.f(nil); err != nil {
return err
}
}
if len(args) == 0 {
return n.writeHelp()
}
return n.child.Parse(args)
}
if n.f == nil {
n.printf("%q has no subcommands", n.name)
return ErrEmptyTree
}
return n.f(args)
}
func (n *node) printf(format string, a ...any) {
if n.logf == nil {
log.Printf(format, a...)
} else {
n.logf(format, a...)
}
}
func (n *node) MustParse(arguments []string, handleError func(error)) {
switch err := n.Parse(arguments); err {
case nil:
return
case ErrHelp:
os.Exit(0)
case ErrNoMatch:
os.Exit(1)
case ErrEmptyTree:
os.Exit(1)
default:
var flagError FlagError
if !errors.As(err, &flagError) { // returned by HandlerFunc
handleError(err)
os.Exit(1)
}
if flagError.Success() {
os.Exit(0)
}
os.Exit(1)
}
}

View File

@ -1,344 +0,0 @@
package command_test
import (
"bytes"
"errors"
"flag"
"fmt"
"io"
"log"
"strings"
"testing"
"git.gensokyo.uk/security/fortify/command"
)
func TestParse(t *testing.T) {
testCases := []struct {
name string
buildTree func(wout, wlog io.Writer) command.Command
args []string
want string
wantLog string
wantErr error
}{
{
"d=0 empty sub",
func(wout, wlog io.Writer) command.Command { return command.New(wout, newLogFunc(wlog), "root", nil) },
[]string{""},
"", "test: \"root\" has no subcommands\n", command.ErrEmptyTree,
},
{
"d=0 empty sub garbage",
func(wout, wlog io.Writer) command.Command { return command.New(wout, newLogFunc(wlog), "root", nil) },
[]string{"a", "b", "c", "d"},
"", "test: \"root\" has no subcommands\n", command.ErrEmptyTree,
},
{
"d=0 no match",
buildTestCommand,
[]string{"nonexistent"},
"", "test: \"nonexistent\" is not a valid command\n", command.ErrNoMatch,
},
{
"d=0 direct error",
buildTestCommand,
[]string{"error"},
"", "", errSuccess,
},
{
"d=0 direct error garbage",
buildTestCommand,
[]string{"error", "0", "1", "2"},
"", "", errSuccess,
},
{
"d=0 direct success out of order",
buildTestCommand,
[]string{"succeed"},
"", "", nil,
},
{
"d=0 direct success output",
buildTestCommand,
[]string{"print", "0", "1", "2"},
"012", "", nil,
},
{
"d=0 out of order string flag",
buildTestCommand,
[]string{"string", "--string", "64d3b4b7b21788585845060e2199a78f"},
"flag provided but not defined: -string\n\nUsage:\ttest string [-h | --help] COMMAND [OPTIONS]\n\n", "",
errors.New("flag provided but not defined: -string"),
},
{
"d=0 string flag",
buildTestCommand,
[]string{"--string", "64d3b4b7b21788585845060e2199a78f", "string"},
"64d3b4b7b21788585845060e2199a78f", "", nil,
},
{
"d=0 int flag",
buildTestCommand,
[]string{"--int", "2147483647", "int"},
"2147483647", "", nil,
},
{
"d=0 repeat flag",
buildTestCommand,
[]string{"--repeat", "0", "--repeat", "1", "--repeat", "2", "--repeat", "3", "--repeat", "4", "repeat"},
"[0 1 2 3 4]", "", nil,
},
{
"d=0 bool flag",
buildTestCommand,
[]string{"-v", "succeed"},
"", "test: verbose\n", nil,
},
{
"d=0 bool flag early error",
buildTestCommand,
[]string{"--fail", "succeed"},
"", "", errSuccess,
},
{
"d=1 empty sub",
buildTestCommand,
[]string{"empty"},
"", "test: \"empty\" has no subcommands\n", command.ErrEmptyTree,
},
{
"d=1 empty sub garbage",
buildTestCommand,
[]string{"empty", "a", "b", "c", "d"},
"", "test: \"empty\" has no subcommands\n", command.ErrEmptyTree,
},
{
"d=1 empty sub help",
buildTestCommand,
[]string{"empty", "-h"},
"\nUsage:\ttest empty [-h | --help] COMMAND [OPTIONS]\n\n", "", flag.ErrHelp,
},
{
"d=1 no match",
buildTestCommand,
[]string{"join", "23aa3bb0", "34986782", "d8859355", "cd9ac317", ", "},
"", "test: \"23aa3bb0\" is not a valid command\n", command.ErrNoMatch,
},
{
"d=1 direct success out",
buildTestCommand,
[]string{"join", "out", "23aa3bb0", "34986782", "d8859355", "cd9ac317", ", "},
"23aa3bb0, 34986782, d8859355, cd9ac317", "", nil,
},
{
"d=1 direct success log",
buildTestCommand,
[]string{"join", "log", "23aa3bb0", "34986782", "d8859355", "cd9ac317", ", "},
"", "test: 23aa3bb0, 34986782, d8859355, cd9ac317\n", nil,
},
{
"d=4 empty sub",
buildTestCommand,
[]string{"deep", "d=2", "d=3", "d=4"},
"", "test: \"d=4\" has no subcommands\n", command.ErrEmptyTree},
{
"d=0 help",
buildTestCommand,
[]string{},
`
Usage: test [-h | --help] [-v] [--fail] [--string <value>] [--int <int>] [--repeat <value>] COMMAND [OPTIONS]
Commands:
error return an error
print wraps Fprint
string print string passed by flag
int print int passed by flag
repeat print repeated values passed by flag
empty empty subcommand
join wraps strings.Join
succeed this command succeeds
deep top level of command tree with various levels
`, "", command.ErrHelp,
},
{
"d=0 help flag",
buildTestCommand,
[]string{"-h"},
`
Usage: test [-h | --help] [-v] [--fail] [--string <value>] [--int <int>] [--repeat <value>] COMMAND [OPTIONS]
Commands:
error return an error
print wraps Fprint
string print string passed by flag
int print int passed by flag
repeat print repeated values passed by flag
empty empty subcommand
join wraps strings.Join
succeed this command succeeds
deep top level of command tree with various levels
Flags:
-fail
fail early
-int int
store value for the "int" command (default -1)
-repeat value
store value for the "repeat" command
-string string
store value for the "string" command (default "default")
-v verbose output
`, "", flag.ErrHelp,
},
{
"d=1 help",
buildTestCommand,
[]string{"join"},
`
Usage: test join [-h | --help] COMMAND [OPTIONS]
Commands:
out write result to wout
log log result to wlog
`, "", command.ErrHelp,
},
{
"d=1 help flag",
buildTestCommand,
[]string{"join", "-h"},
`
Usage: test join [-h | --help] COMMAND [OPTIONS]
Commands:
out write result to wout
log log result to wlog
`, "", flag.ErrHelp,
},
{
"d=2 help",
buildTestCommand,
[]string{"deep", "d=2"},
`
Usage: test deep d=2 [-h | --help] COMMAND [OPTIONS]
Commands:
d=3 relative third level
`, "", command.ErrHelp,
},
{
"d=2 help flag",
buildTestCommand,
[]string{"deep", "d=2", "-h"},
`
Usage: test deep d=2 [-h | --help] COMMAND [OPTIONS]
Commands:
d=3 relative third level
`, "", flag.ErrHelp,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
wout, wlog := new(bytes.Buffer), new(bytes.Buffer)
c := tc.buildTree(wout, wlog)
if err := c.Parse(tc.args); !errors.Is(err, tc.wantErr) {
t.Errorf("Parse: error = %v; wantErr %v", err, tc.wantErr)
}
if got := wout.String(); got != tc.want {
t.Errorf("Parse: %s want %s", got, tc.want)
}
if gotLog := wlog.String(); gotLog != tc.wantLog {
t.Errorf("Parse: log = %s wantLog %s", gotLog, tc.wantLog)
}
})
}
}
var (
errJoinLen = errors.New("not enough arguments to join")
errSuccess = errors.New("success")
)
func buildTestCommand(wout, wlog io.Writer) (c command.Command) {
var (
flagVerbose bool
flagFail bool
flagString string
flagInt int
flagRepeat command.RepeatableFlag
)
logf := newLogFunc(wlog)
c = command.New(wout, logf, "test", func([]string) error {
if flagVerbose {
logf("verbose")
}
if flagFail {
return errSuccess
}
return nil
}).
Flag(&flagVerbose, "v", command.BoolFlag(false), "verbose output").
Flag(&flagFail, "fail", command.BoolFlag(false), "fail early").
Command("error", "return an error", func([]string) error {
return errSuccess
}).
Command("print", "wraps Fprint", func(args []string) error {
a := make([]any, len(args))
for i, v := range args {
a[i] = v
}
_, err := fmt.Fprint(wout, a...)
return err
}).
Flag(&flagString, "string", command.StringFlag("default"), "store value for the \"string\" command").
Command("string", "print string passed by flag", func(args []string) error { _, err := fmt.Fprint(wout, flagString); return err }).
Flag(&flagInt, "int", command.IntFlag(-1), "store value for the \"int\" command").
Command("int", "print int passed by flag", func(args []string) error { _, err := fmt.Fprint(wout, flagInt); return err }).
Flag(nil, "repeat", &flagRepeat, "store value for the \"repeat\" command").
Command("repeat", "print repeated values passed by flag", func(args []string) error { _, err := fmt.Fprint(wout, flagRepeat); return err })
c.New("empty", "empty subcommand")
c.New("hidden", command.UsageInternal)
c.New("join", "wraps strings.Join").
Command("out", "write result to wout", func(args []string) error {
if len(args) == 0 {
return errJoinLen
}
_, err := fmt.Fprint(wout, strings.Join(args[:len(args)-1], args[len(args)-1]))
return err
}).
Command("log", "log result to wlog", func(args []string) error {
if len(args) == 0 {
return errJoinLen
}
logf("%s", strings.Join(args[:len(args)-1], args[len(args)-1]))
return nil
})
c.Command("succeed", "this command succeeds", func([]string) error { return nil })
c.New("deep", "top level of command tree with various levels").
New("d=2", "relative second level").
New("d=3", "relative third level").
New("d=4", "relative fourth level")
return
}
func newLogFunc(w io.Writer) command.LogFunc { return log.New(w, "test: ", 0).Printf }

View File

@ -1,54 +0,0 @@
package command
import (
"flag"
"testing"
)
func TestParseUnreachable(t *testing.T) {
// top level bypasses name matching and recursive calls to Parse
// returns when encountering zero-length args
t.Run("zero-length args", func(t *testing.T) {
defer checkRecover(t, "Parse", "attempted to parse with zero length args")
_ = newNode(panicWriter{}, nil, " ", " ").Parse(nil)
})
// top level must not have siblings
t.Run("toplevel siblings", func(t *testing.T) {
defer checkRecover(t, "Parse", "invalid toplevel state")
n := newNode(panicWriter{}, nil, " ", "")
n.append(newNode(panicWriter{}, nil, " ", " "))
_ = n.Parse(nil)
})
// a node with descendents must not have a direct handler
t.Run("sub handle conflict", func(t *testing.T) {
defer checkRecover(t, "Parse", "invalid subcommand tree state")
n := newNode(panicWriter{}, nil, " ", " ")
n.adopt(newNode(panicWriter{}, nil, " ", " "))
n.f = func([]string) error { panic("unreachable") }
_ = n.Parse([]string{" "})
})
// this would only happen if a node was matched twice
t.Run("parsed flag set", func(t *testing.T) {
defer checkRecover(t, "Parse", "invalid set state")
n := newNode(panicWriter{}, nil, " ", "")
set := flag.NewFlagSet("parsed", flag.ContinueOnError)
set.SetOutput(panicWriter{})
_ = set.Parse(nil)
n.set = set
_ = n.Parse(nil)
})
}
type panicWriter struct{}
func (p panicWriter) Write([]byte) (int, error) { panic("unreachable") }
func checkRecover(t *testing.T, name, wantPanic string) {
if r := recover(); r != wantPanic {
t.Errorf("%s: panic = %v; wantPanic %v",
name, r, wantPanic)
}
}

View File

@ -1,14 +0,0 @@
package command
// the top level node wants [Command] returned for its builder methods
type rootNode struct{ *node }
func (r rootNode) Command(name, usage string, f HandlerFunc) Command {
r.node.Command(name, usage, f)
return r
}
func (r rootNode) Flag(p any, name string, value FlagDefiner, usage string) Command {
r.node.Flag(p, name, value, usage)
return r
}

View File

@ -1,22 +1,12 @@
package dbus_test package dbus_test
import ( import (
"bytes"
"context"
"errors" "errors"
"fmt"
"os"
"os/exec"
"strings" "strings"
"syscall"
"testing" "testing"
"time"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/internal"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/sandbox"
) )
func TestNew(t *testing.T) { func TestNew(t *testing.T) {
@ -72,7 +62,7 @@ func TestProxy_Seal(t *testing.T) {
for id, tc := range testCasePairs() { for id, tc := range testCasePairs() {
t.Run("create seal for "+id, func(t *testing.T) { t.Run("create seal for "+id, func(t *testing.T) {
p := dbus.New(tc[0].bus, tc[1].bus) p := dbus.New(tc[0].bus, tc[1].bus)
if err := p.Seal(tc[0].c, tc[1].c); (errors.Is(err, syscall.EINVAL)) != tc[0].wantErr { if err := p.Seal(tc[0].c, tc[1].c); (errors.Is(err, helper.ErrContainsNull)) != tc[0].wantErr {
t.Errorf("Seal(%p, %p) error = %v, wantErr %v", t.Errorf("Seal(%p, %p) error = %v, wantErr %v",
tc[0].c, tc[1].c, tc[0].c, tc[1].c,
err, tc[0].wantErr) err, tc[0].wantErr)
@ -108,20 +98,15 @@ func TestProxy_Seal(t *testing.T) {
} }
func TestProxy_Start_Wait_Close_String(t *testing.T) { func TestProxy_Start_Wait_Close_String(t *testing.T) {
oldWaitDelay := helper.WaitDelay t.Run("sandboxed", func(t *testing.T) {
helper.WaitDelay = 16 * time.Second
t.Cleanup(func() { helper.WaitDelay = oldWaitDelay })
t.Run("sandbox", func(t *testing.T) {
proxyName := dbus.ProxyName
dbus.ProxyName = os.Args[0]
t.Cleanup(func() { dbus.ProxyName = proxyName })
testProxyStartWaitCloseString(t, true) testProxyStartWaitCloseString(t, true)
}) })
t.Run("direct", func(t *testing.T) { testProxyStartWaitCloseString(t, false) }) t.Run("direct", func(t *testing.T) {
testProxyStartWaitCloseString(t, false)
})
} }
func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) { func testProxyStartWaitCloseString(t *testing.T, sandbox bool) {
for id, tc := range testCasePairs() { for id, tc := range testCasePairs() {
// this test does not test errors // this test does not test errors
if tc[0].wantErr { if tc[0].wantErr {
@ -138,33 +123,14 @@ func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) {
}) })
t.Run("proxy for "+id, func(t *testing.T) { t.Run("proxy for "+id, func(t *testing.T) {
helper.InternalReplaceExecCommand(t)
overridePath(t)
p := dbus.New(tc[0].bus, tc[1].bus) p := dbus.New(tc[0].bus, tc[1].bus)
p.CommandContext = func(ctx context.Context) (cmd *exec.Cmd) {
return exec.CommandContext(ctx, os.Args[0], "-test.v",
"-test.run=TestHelperInit", "--", "init")
}
p.CmdF = func(v any) {
if useSandbox {
container := v.(*sandbox.Container)
if container.Args[0] != dbus.ProxyName {
panic(fmt.Sprintf("unexpected argv0 %q", os.Args[0]))
}
container.Args = append([]string{os.Args[0], "-test.run=TestHelperStub", "--"}, container.Args[1:]...)
} else {
cmd := v.(*exec.Cmd)
if cmd.Args[0] != dbus.ProxyName {
panic(fmt.Sprintf("unexpected argv0 %q", os.Args[0]))
}
cmd.Err = nil
cmd.Path = os.Args[0]
cmd.Args = append([]string{os.Args[0], "-test.run=TestHelperStub", "--"}, cmd.Args[1:]...)
}
}
p.FilterF = func(v []byte) []byte { return bytes.SplitN(v, []byte("TestHelperInit\n"), 2)[1] }
output := new(strings.Builder) output := new(strings.Builder)
t.Run("unsealed", func(t *testing.T) { t.Run("unsealed behaviour of "+id, func(t *testing.T) {
t.Run("string", func(t *testing.T) { t.Run("unsealed string of "+id, func(t *testing.T) {
want := "(unsealed dbus proxy)" want := "(unsealed dbus proxy)"
if got := p.String(); got != want { if got := p.String(); got != want {
t.Errorf("String() = %v, want %v", t.Errorf("String() = %v, want %v",
@ -173,17 +139,17 @@ func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) {
} }
}) })
t.Run("start", func(t *testing.T) { t.Run("unsealed start of "+id, func(t *testing.T) {
want := "proxy not sealed" want := "proxy not sealed"
if err := p.Start(context.Background(), nil, useSandbox); err == nil || err.Error() != want { if err := p.Start(nil, nil, sandbox); err == nil || err.Error() != want {
t.Errorf("Start() error = %v, wantErr %q", t.Errorf("Start() error = %v, wantErr %q",
err, errors.New(want)) err, errors.New(want))
return return
} }
}) })
t.Run("wait", func(t *testing.T) { t.Run("unsealed wait of "+id, func(t *testing.T) {
wantErr := "dbus: not started" wantErr := "proxy not started"
if err := p.Wait(); err == nil || err.Error() != wantErr { if err := p.Wait(); err == nil || err.Error() != wantErr {
t.Errorf("Wait() error = %v, wantErr %v", t.Errorf("Wait() error = %v, wantErr %v",
err, wantErr) err, wantErr)
@ -200,7 +166,7 @@ func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) {
} }
}) })
t.Run("sealed", func(t *testing.T) { t.Run("sealed behaviour of "+id, func(t *testing.T) {
want := strings.Join(append(tc[0].want, tc[1].want...), " ") want := strings.Join(append(tc[0].want, tc[1].want...), " ")
if got := p.String(); got != want { if got := p.String(); got != want {
t.Errorf("String() = %v, want %v", t.Errorf("String() = %v, want %v",
@ -208,20 +174,14 @@ func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) {
return return
} }
t.Run("start", func(t *testing.T) { t.Run("sealed start of "+id, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) if err := p.Start(nil, output, sandbox); err != nil {
defer cancel()
if err := p.Start(ctx, output, useSandbox); err != nil {
t.Fatalf("Start(nil, nil) error = %v", t.Fatalf("Start(nil, nil) error = %v",
err) err)
} }
t.Run("string", func(t *testing.T) { t.Run("started string of "+id, func(t *testing.T) {
wantSubstr := fmt.Sprintf("%s -test.run=TestHelperStub -- --args=3 --fd=4", os.Args[0]) wantSubstr := dbus.ProxyName + " --args="
if useSandbox {
wantSubstr = fmt.Sprintf(`argv: ["%s" "-test.run=TestHelperStub" "--" "--args=3" "--fd=4"], flags: 0x0, seccomp: 0x3e`, os.Args[0])
}
if got := p.String(); !strings.Contains(got, wantSubstr) { if got := p.String(); !strings.Contains(got, wantSubstr) {
t.Errorf("String() = %v, want %v", t.Errorf("String() = %v, want %v",
p.String(), wantSubstr) p.String(), wantSubstr)
@ -229,8 +189,22 @@ func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) {
} }
}) })
t.Run("wait", func(t *testing.T) { t.Run("sealed closing of "+id+" without status", func(t *testing.T) {
p.Close() wantPanic := "attempted to close helper with no status pipe"
defer func() {
if r := recover(); r != wantPanic {
t.Errorf("Close() panic = %v, wantPanic %v",
r, wantPanic)
}
}()
if err := p.Close(); err != nil {
t.Errorf("Close() error = %v",
err)
}
})
t.Run("started wait of "+id, func(t *testing.T) {
if err := p.Wait(); err != nil { if err := p.Wait(); err != nil {
t.Errorf("Wait() error = %v\noutput: %s", t.Errorf("Wait() error = %v\noutput: %s",
err, output.String()) err, output.String())
@ -242,10 +216,10 @@ func testProxyStartWaitCloseString(t *testing.T, useSandbox bool) {
} }
} }
func TestHelperInit(t *testing.T) { func overridePath(t *testing.T) {
if len(os.Args) != 5 || os.Args[4] != "init" { proxyName := dbus.ProxyName
return dbus.ProxyName = "/nonexistent-xdg-dbus-proxy"
} t.Cleanup(func() {
sandbox.SetOutput(fmsg.Output{}) dbus.ProxyName = proxyName
sandbox.Init(fmsg.Prepare, internal.InstallFmsg) })
} }

View File

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

View File

@ -1,14 +1,13 @@
package dbus package dbus
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"os/exec"
"sync" "sync"
"git.gensokyo.uk/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/helper/bwrap"
) )
// ProxyName is the file name or path to the proxy program. // ProxyName is the file name or path to the proxy program.
@ -19,25 +18,30 @@ var ProxyName = "xdg-dbus-proxy"
// Once sealed, configuration changes will no longer be possible and attempting to do so will result in a panic. // Once sealed, configuration changes will no longer be possible and attempting to do so will result in a panic.
type Proxy struct { type Proxy struct {
helper helper.Helper helper helper.Helper
ctx context.Context bwrap *bwrap.Config
cancel context.CancelCauseFunc
name string name string
session [2]string session [2]string
system [2]string system [2]string
CmdF func(any)
sysP bool
CommandContext func(ctx context.Context) (cmd *exec.Cmd)
FilterF func([]byte) []byte
seal io.WriterTo seal io.WriterTo
lock sync.RWMutex lock sync.RWMutex
} }
func (p *Proxy) Session() [2]string { return p.session } func (p *Proxy) Session() [2]string {
func (p *Proxy) System() [2]string { return p.system } return p.session
func (p *Proxy) Sealed() bool { p.lock.RLock(); defer p.lock.RUnlock(); return p.seal != nil } }
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")
@ -52,7 +56,7 @@ func (p *Proxy) String() string {
defer p.lock.RUnlock() defer p.lock.RUnlock()
if p.helper != nil { if p.helper != nil {
return p.helper.String() return p.helper.Unwrap().String()
} }
if p.seal != nil { if p.seal != nil {
@ -62,6 +66,10 @@ func (p *Proxy) String() string {
return "(unsealed dbus proxy)" return "(unsealed dbus proxy)"
} }
func (p *Proxy) Bwrap() []string {
return p.bwrap.Args()
}
// Seal seals the Proxy instance. // Seal seals the Proxy instance.
func (p *Proxy) Seal(session, system *Config) error { func (p *Proxy) Seal(session, system *Config) error {
p.lock.Lock() p.lock.Lock()
@ -81,7 +89,6 @@ func (p *Proxy) Seal(session, system *Config) error {
} }
if system != nil { if system != nil {
args = append(args, system.Args(p.system)...) args = append(args, system.Args(p.system)...)
p.sysP = true
} }
if seal, err := helper.NewCheckedArgs(args); err != nil { if seal, err := helper.NewCheckedArgs(args); err != nil {
return err return err

145
dbus/run.go Normal file
View File

@ -0,0 +1,145 @@
package dbus
import (
"errors"
"io"
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"
"git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/ldd"
)
// Start launches the D-Bus proxy and sets up the Wait method.
// ready should be buffered and must only be received from once.
func (p *Proxy) Start(ready chan error, output io.Writer, sandbox bool) error {
p.lock.Lock()
defer p.lock.Unlock()
if p.seal == nil {
return errors.New("proxy not sealed")
}
var (
h helper.Helper
cmd *exec.Cmd
argF = func(argsFD, statFD int) []string {
if statFD == -1 {
return []string{"--args=" + strconv.Itoa(argsFD)}
} else {
return []string{"--args=" + strconv.Itoa(argsFD), "--fd=" + strconv.Itoa(statFD)}
}
}
)
if !sandbox {
h = helper.New(p.seal, p.name, argF)
cmd = h.Unwrap()
// xdg-dbus-proxy does not need to inherit the environment
cmd.Env = []string{}
} else {
// look up absolute path if name is just a file name
toolPath := p.name
if filepath.Base(p.name) == p.name {
if s, err := exec.LookPath(p.name); err != nil {
return err
} else {
toolPath = s
}
}
// resolve libraries by parsing ldd output
var proxyDeps []*ldd.Entry
if toolPath != "/nonexistent-xdg-dbus-proxy" {
if l, err := ldd.Exec(toolPath); err != nil {
return err
} else {
proxyDeps = l
}
}
bc := &bwrap.Config{
Unshare: nil,
Hostname: "fortify-dbus",
Chdir: "/",
Clearenv: true,
NewSession: true,
DieWithParent: true,
}
// resolve proxy socket directories
bindTarget := make(map[string]struct{}, 2)
for _, ps := range []string{p.session[1], p.system[1]} {
if pd := path.Dir(ps); len(pd) > 0 {
if pd[0] == '/' {
bindTarget[pd] = struct{}{}
}
}
}
for k := range bindTarget {
bc.Bind(k, k, false, true)
}
roBindTarget := make(map[string]struct{}, 2+1+len(proxyDeps))
// xdb-dbus-proxy bin and dependencies
roBindTarget[path.Dir(toolPath)] = struct{}{}
for _, ent := range proxyDeps {
if path.IsAbs(ent.Path) {
roBindTarget[path.Dir(ent.Path)] = struct{}{}
}
if path.IsAbs(ent.Name) {
roBindTarget[path.Dir(ent.Name)] = struct{}{}
}
}
// resolve upstream bus directories
for _, as := range []string{p.session[0], p.system[0]} {
if len(as) > 0 && strings.HasPrefix(as, "unix:path=/") {
// leave / intact
roBindTarget[path.Dir(as[10:])] = struct{}{}
}
}
for k := range roBindTarget {
bc.Bind(k, k)
}
h = helper.MustNewBwrap(bc, p.seal, toolPath, argF)
cmd = h.Unwrap()
p.bwrap = bc
}
if output != nil {
cmd.Stdout = output
cmd.Stderr = output
}
if err := h.StartNotify(ready); err != nil {
return err
}
p.helper = h
return nil
}
// Wait waits for xdg-dbus-proxy to exit or fault.
func (p *Proxy) Wait() error {
p.lock.RLock()
defer p.lock.RUnlock()
if p.helper == nil {
return errors.New("proxy not started")
}
return p.helper.Wait()
}
// Close closes the status file descriptor passed to xdg-dbus-proxy, causing it to stop.
func (p *Proxy) Close() error {
return p.helper.Close()
}

View File

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

View File

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

4
dist/install.sh vendored
View File

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

13
dist/release.sh vendored
View File

@ -8,12 +8,13 @@ mkdir -p "${out}"
cp -v "README.md" "dist/fsurc.default" "dist/install.sh" "${out}" cp -v "README.md" "dist/fsurc.default" "dist/install.sh" "${out}"
cp -rv "comp" "${out}" cp -rv "comp" "${out}"
go generate ./... go build -trimpath -v -o "${out}/bin/" -ldflags "-s -w
go build -trimpath -v -o "${out}/bin/" -ldflags "-s -w -buildid= -extldflags '-static' -X git.gensokyo.uk/security/fortify/internal.Version=${VERSION}
-X git.gensokyo.uk/security/fortify/internal.version=${VERSION} -X git.gensokyo.uk/security/fortify/internal.Fortify=/usr/bin/fortify
-X git.gensokyo.uk/security/fortify/internal.fsu=/usr/bin/fsu -X git.gensokyo.uk/security/fortify/internal.Fsu=/usr/bin/fsu
-X main.fmain=/usr/bin/fortify -X git.gensokyo.uk/security/fortify/internal.Finit=/usr/libexec/fortify/finit
-X main.fpkg=/usr/bin/fpkg" ./... -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 -f "./${out}.tar.gz" && tar -C dist -czf "${out}.tar.gz" "${pname}"
rm -rf "./${out}" rm -rf "./${out}"

55
error.go Normal file
View File

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

14
flake.lock generated
View File

@ -7,11 +7,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1742655702, "lastModified": 1735344290,
"narHash": "sha256-jbqlw4sPArFtNtA1s3kLg7/A4fzP4GLk9bGbtUJg0JQ=", "narHash": "sha256-oJDtWPH1oJT34RJK1FSWjwX4qcGOBRkcNQPD0EbSfNM=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "0948aeedc296f964140d9429223c7e4a0702a1ff", "rev": "613691f285dad87694c2ba1c9e6298d04736292d",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -23,16 +23,16 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1743231893, "lastModified": 1735326919,
"narHash": "sha256-tpJsHMUPEhEnzySoQxx7+kA+KUtgWqvlcUBqROYNNt0=", "narHash": "sha256-BZlgs4l9CXAauo78giGCZdazMMk5VZNro7o5SHFUuyE=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "c570c1f5304493cafe133b8d843c7c1c4a10d3a6", "rev": "8f0aa155aa29f7d2b471aa2ffd322745bf2b2036",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-24.11", "ref": "nixos-24.11-small",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }

116
flake.nix
View File

@ -2,7 +2,7 @@
description = "fortify sandbox tool and nixos module"; description = "fortify sandbox tool and nixos module";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11-small";
home-manager = { home-manager = {
url = "github:nix-community/home-manager/release-24.11"; url = "github:nix-community/home-manager/release-24.11";
@ -27,12 +27,12 @@
nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
in in
{ {
nixosModules.fortify = import ./nixos.nix self.packages; nixosModules.fortify = import ./nixos.nix;
buildPackage = forAllSystems ( buildPackage = forAllSystems (
system: system:
nixpkgsFor.${system}.callPackage ( nixpkgsFor.${system}.callPackage (
import ./cmd/fpkg/build.nix { import ./bundle.nix {
inherit inherit
nixpkgsFor nixpkgsFor
system system
@ -57,30 +57,18 @@
; ;
in in
{ {
fortify = callPackage ./test { inherit system self; }; check-formatting =
race = callPackage ./test { runCommandLocal "check-formatting" { nativeBuildInputs = [ nixfmt-rfc-style ]; }
inherit system self; ''
withRace = true;
};
sandbox = callPackage ./test/sandbox { inherit self; };
sandbox-race = callPackage ./test/sandbox {
inherit self;
withRace = true;
};
fpkg = callPackage ./cmd/fpkg/test { inherit system self; };
formatting = runCommandLocal "check-formatting" { nativeBuildInputs = [ nixfmt-rfc-style ]; } ''
cd ${./.} cd ${./.}
echo "running nixfmt..." echo "running nixfmt..."
nixfmt --width=256 --check . nixfmt --check .
touch $out touch $out
''; '';
lint = check-lint =
runCommandLocal "check-lint" runCommandLocal "check-lint"
{ {
nativeBuildInputs = [ nativeBuildInputs = [
@ -99,68 +87,57 @@
touch $out touch $out
''; '';
nixos-tests = callPackage ./test.nix { inherit system self home-manager; };
} }
); );
packages = forAllSystems ( packages = forAllSystems (
system: system:
let let
inherit (self.packages.${system}) fortify fsu;
pkgs = nixpkgsFor.${system}; pkgs = nixpkgsFor.${system};
in in
{ {
default = fortify; default = self.packages.${system}.fortify;
fortify = pkgs.pkgsStatic.callPackage ./package.nix {
inherit (pkgs)
# passthru.buildInputs
go
gcc
# nativeBuildInputs fortify = pkgs.callPackage ./package.nix { };
pkg-config
wayland-scanner
makeBinaryWrapper
# appPackages
glibc
xdg-dbus-proxy
# fpkg
zstd
gnutar
coreutils
;
};
fsu = pkgs.callPackage ./cmd/fsu/package.nix { inherit (self.packages.${system}) fortify; };
dist = pkgs.runCommand "${fortify.name}-dist" { buildInputs = fortify.targetPkgs ++ [ pkgs.pkgsStatic.musl ]; } ''
# go requires XDG_CACHE_HOME for the build cache
export XDG_CACHE_HOME="$(mktemp -d)"
# get a different workdir as go does not like /build
cd $(mktemp -d) \
&& cp -r ${fortify.src}/. . \
&& chmod +w cmd && cp -r ${fsu.src}/. cmd/fsu/ \
&& chmod -R +w .
export FORTIFY_VERSION="v${fortify.version}"
./dist/release.sh && mkdir $out && cp -v "dist/fortify-$FORTIFY_VERSION.tar.gz"* $out
'';
} }
); );
devShells = forAllSystems ( devShells = forAllSystems (system: {
system: default = nixpkgsFor.${system}.mkShell {
let buildInputs = with nixpkgsFor.${system}; self.packages.${system}.fortify.buildInputs;
inherit (self.packages.${system}) fortify; };
pkgs = nixpkgsFor.${system};
in fhs = nixpkgsFor.${system}.buildFHSEnv {
{ pname = "fortify-fhs";
default = pkgs.mkShell { buildInputs = fortify.targetPkgs; }; inherit (self.packages.${system}.fortify) version;
withPackage = pkgs.mkShell { buildInputs = [ fortify ] ++ fortify.targetPkgs; }; 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 =
with nixpkgsFor.${system};
self.packages.${system}.fortify.buildInputs ++ [ self.packages.${system}.fortify ];
};
generateDoc = generateDoc =
let let
pkgs = nixpkgsFor.${system};
inherit (pkgs) lib; inherit (pkgs) lib;
doc = doc =
@ -169,7 +146,7 @@
specialArgs = { specialArgs = {
inherit pkgs; inherit pkgs;
}; };
modules = [ (import ./options.nix self.packages) ]; modules = [ ./options.nix ];
}; };
cleanEval = lib.filterAttrsRecursive (n: _: n != "_module") eval; cleanEval = lib.filterAttrsRecursive (n: _: n != "_module") eval;
in in
@ -179,12 +156,11 @@
sed -i '/*Declared by:*/,+1 d' $out sed -i '/*Declared by:*/,+1 d' $out
''; '';
in in
pkgs.mkShell { nixpkgsFor.${system}.mkShell {
shellHook = '' shellHook = ''
exec cat ${docText} > options.md exec cat ${docText} > options.md
''; '';
}; };
} });
);
}; };
} }

View File

@ -1,59 +1,50 @@
// Package fst exports shared fortify types.
package fst package fst
import ( import (
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/system" "git.gensokyo.uk/security/fortify/internal/system"
) )
const Tmp = "/.fortify" const Tmp = "/.fortify"
// Config is used to seal an app implementation. // Config is used to seal an *App
type Config struct { type Config struct {
// reverse-DNS style arbitrary identifier string from config; // application ID
// passed to wayland security-context-v1 as application ID
// and used as part of defaults in dbus session proxy
ID string `json:"id"` ID string `json:"id"`
// value passed through to the child process as its argv
Command []string `json:"command"`
// absolute path to executable file Confinement ConfinementConfig `json:"confinement"`
Path string `json:"path,omitempty"` }
// final args passed to container init
Args []string `json:"args"` // ConfinementConfig defines fortified child's confinement
type ConfinementConfig struct {
// system services to make available in the container // numerical application id, determines uid in the init namespace
Enablements system.Enablement `json:"enablements"` AppID int `json:"app_id"`
// list of supplementary groups to inherit
// session D-Bus proxy configuration; Groups []string `json:"groups"`
// nil makes session bus proxy assume built-in defaults // passwd username in the sandbox, defaults to passwd name of target uid or chronos
SessionBus *dbus.Config `json:"session_bus,omitempty"` Username string `json:"username,omitempty"`
// system D-Bus proxy configuration; // home directory in sandbox, empty for outer
// nil disables system bus proxy Inner string `json:"home_inner"`
SystemBus *dbus.Config `json:"system_bus,omitempty"` // home directory in init namespace
// direct access to wayland socket; when this gets set no attempt is made to attach security-context-v1 Outer string `json:"home"`
// and the bare socket is mounted to the sandbox // bwrap sandbox confinement configuration
DirectWayland bool `json:"direct_wayland,omitempty"` Sandbox *SandboxConfig `json:"sandbox"`
// extra acl entries to append
// passwd username in container, defaults to passwd name of target uid or chronos ExtraPerms []*ExtraPermConfig `json:"extra_perms,omitempty"`
Username string `json:"username,omitempty"`
// absolute path to shell, empty for host shell // reference to a system D-Bus proxy configuration,
Shell string `json:"shell,omitempty"` // nil value disables system bus proxy
// absolute path to home directory in the init mount namespace SystemBus *dbus.Config `json:"system_bus,omitempty"`
Data string `json:"data"` // reference to a session D-Bus proxy configuration,
// directory to enter and use as home in the container mount namespace, empty for Data // nil value makes session bus proxy assume built-in defaults
Dir string `json:"dir"` SessionBus *dbus.Config `json:"session_bus,omitempty"`
// extra acl ops, dispatches before container init
ExtraPerms []*ExtraPermConfig `json:"extra_perms,omitempty"` // system resources to expose to the sandbox
Enablements system.Enablements `json:"enablements"`
// numerical application id, used for init user namespace credentials
Identity int `json:"identity"`
// list of supplementary groups inherited by container processes
Groups []string `json:"groups"`
// abstract container configuration baseline
Container *ContainerConfig `json:"container"`
} }
// ExtraPermConfig describes an acl update op.
type ExtraPermConfig struct { type ExtraPermConfig struct {
Ensure bool `json:"ensure,omitempty"` Ensure bool `json:"ensure,omitempty"`
Path string `json:"path"` Path string `json:"path"`
@ -81,3 +72,86 @@ func (e *ExtraPermConfig) String() string {
} }
return string(buf) return string(buf)
} }
type FilesystemConfig struct {
// mount point in sandbox, same as src if empty
Dst string `json:"dst,omitempty"`
// host filesystem path to make available to sandbox
Src string `json:"src"`
// write access
Write bool `json:"write,omitempty"`
// device access
Device bool `json:"dev,omitempty"`
// fail if mount fails
Must bool `json:"require,omitempty"`
}
// Template returns a fully populated instance of Config.
func Template() *Config {
return &Config{
ID: "org.chromium.Chromium",
Command: []string{
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland",
},
Confinement: ConfinementConfig{
AppID: 9,
Groups: []string{"video"},
Username: "chronos",
Outer: "/var/lib/persist/home/org.chromium.Chromium",
Inner: "/var/lib/fortify",
Sandbox: &SandboxConfig{
Hostname: "localhost",
UserNS: true,
Net: true,
NoNewSession: true,
MapRealUID: true,
Dev: true,
DirectWayland: false,
// example API credentials pulled from Google Chrome
// DO NOT USE THESE IN A REAL BROWSER
Env: map[string]string{
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT",
},
Filesystem: []*FilesystemConfig{
{Src: "/nix/store"},
{Src: "/run/current-system"},
{Src: "/run/opengl-driver"},
{Src: "/var/db/nix-channels"},
{Src: "/home/chronos", Write: true, Must: true},
{Src: "/dev/dri", Device: true},
},
Link: [][2]string{{"/run/user/65534", "/run/user/150"}},
Etc: "/etc",
AutoEtc: true,
Override: []string{"/var/run/nscd"},
},
SystemBus: &dbus.Config{
See: nil,
Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"},
Own: nil,
Call: nil,
Broadcast: nil,
Log: false,
Filter: true,
},
SessionBus: &dbus.Config{
See: nil,
Talk: []string{"org.freedesktop.Notifications", "org.freedesktop.FileManager1", "org.freedesktop.ScreenSaver",
"org.freedesktop.secrets", "org.kde.kwalletd5", "org.kde.kwalletd6", "org.gnome.SessionManager"},
Own: []string{"org.chromium.Chromium.*", "org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*"},
Call: map[string]string{"org.freedesktop.portal.*": "*"},
Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
Log: false,
Filter: true,
},
Enablements: system.EWayland.Mask() | system.EDBus.Mask() | system.EPulse.Mask(),
},
}
}

View File

@ -1,59 +0,0 @@
package fst
import (
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
)
type (
// ContainerConfig describes the container configuration baseline to which the app implementation adds upon.
ContainerConfig struct {
// container hostname
Hostname string `json:"hostname,omitempty"`
// extra seccomp flags
Seccomp seccomp.FilterOpts `json:"seccomp"`
// allow ptrace and friends
Devel bool `json:"devel,omitempty"`
// allow userns creation in container
Userns bool `json:"userns,omitempty"`
// share host net namespace
Net bool `json:"net,omitempty"`
// allow dangerous terminal I/O
Tty bool `json:"tty,omitempty"`
// allow multiarch
Multiarch bool `json:"multiarch,omitempty"`
// initial process environment variables
Env map[string]string `json:"env"`
// map target user uid to privileged user uid in the user namespace
MapRealUID bool `json:"map_real_uid"`
// pass through all devices
Device bool `json:"device,omitempty"`
// container host filesystem bind mounts
Filesystem []*FilesystemConfig `json:"filesystem"`
// create symlinks inside container filesystem
Link [][2]string `json:"symlink"`
// read-only /etc directory
Etc string `json:"etc,omitempty"`
// automatically set up /etc symlinks
AutoEtc bool `json:"auto_etc"`
// cover these paths or create them if they do not already exist
Cover []string `json:"cover"`
}
// FilesystemConfig is an abstract representation of a bind mount.
FilesystemConfig struct {
// mount point in container, same as src if empty
Dst string `json:"dst,omitempty"`
// host filesystem path to make available to the container
Src string `json:"src"`
// do not mount filesystem read-only
Write bool `json:"write,omitempty"`
// do not disable device files
Device bool `json:"dev,omitempty"`
// fail if the bind mount cannot be established for any reason
Must bool `json:"require,omitempty"`
}
)

View File

@ -1,4 +1,4 @@
package app package fst
import ( import (
"crypto/rand" "crypto/rand"

View File

@ -1,22 +1,22 @@
package app_test package fst_test
import ( import (
"errors" "errors"
"testing" "testing"
. "git.gensokyo.uk/security/fortify/internal/app" "git.gensokyo.uk/security/fortify/fst"
) )
func TestParseAppID(t *testing.T) { func TestParseAppID(t *testing.T) {
t.Run("bad length", func(t *testing.T) { t.Run("bad length", func(t *testing.T) {
if err := ParseAppID(new(ID), "meow"); !errors.Is(err, ErrInvalidLength) { if err := fst.ParseAppID(new(fst.ID), "meow"); !errors.Is(err, fst.ErrInvalidLength) {
t.Errorf("ParseAppID: error = %v, wantErr = %v", err, ErrInvalidLength) t.Errorf("ParseAppID: error = %v, wantErr = %v", err, fst.ErrInvalidLength)
} }
}) })
t.Run("bad byte", func(t *testing.T) { t.Run("bad byte", func(t *testing.T) {
wantErr := "invalid char '\\n' at byte 15" wantErr := "invalid char '\\n' at byte 15"
if err := ParseAppID(new(ID), "02bc7f8936b2af6\n\ne2535cd71ef0bb7"); err == nil || err.Error() != wantErr { 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.Errorf("ParseAppID: error = %v, wantErr = %v", err, wantErr)
} }
}) })
@ -30,30 +30,30 @@ func TestParseAppID(t *testing.T) {
func FuzzParseAppID(f *testing.F) { func FuzzParseAppID(f *testing.F) {
for i := 0; i < 16; i++ { for i := 0; i < 16; i++ {
id := new(ID) id := new(fst.ID)
if err := NewAppID(id); err != nil { if err := fst.NewAppID(id); err != nil {
panic(err.Error()) 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.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) { 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, &ID{b0, b1, b2, b3, b4, b5, b6, b7, b8, b9, b10, b11, b12, b13, b14, b15}) 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) { func testParseAppIDWithRandom(t *testing.T) {
id := new(ID) id := new(fst.ID)
if err := NewAppID(id); err != nil { if err := fst.NewAppID(id); err != nil {
t.Fatalf("cannot generate app ID: %v", err) t.Fatalf("cannot generate app ID: %v", err)
} }
testParseAppID(t, id) testParseAppID(t, id)
} }
func testParseAppID(t *testing.T, id *ID) { func testParseAppID(t *testing.T, id *fst.ID) {
s := id.String() s := id.String()
got := new(ID) got := new(fst.ID)
if err := ParseAppID(got, s); err != nil { if err := fst.ParseAppID(got, s); err != nil {
t.Fatalf("cannot parse app ID: %v", err) t.Fatalf("cannot parse app ID: %v", err)
} }

View File

@ -1,4 +1,4 @@
package common package fst
import ( import (
"path/filepath" "path/filepath"

View File

@ -1,4 +1,4 @@
package common package fst
import ( import (
"testing" "testing"

222
fst/sandbox.go Normal file
View File

@ -0,0 +1,222 @@
package fst
import (
"errors"
"fmt"
"io/fs"
"path"
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/fmsg"
"git.gensokyo.uk/security/fortify/internal/linux"
)
// SandboxConfig describes resources made available to the sandbox.
type SandboxConfig struct {
// unix hostname within sandbox
Hostname string `json:"hostname,omitempty"`
// allow userns within sandbox
UserNS bool `json:"userns,omitempty"`
// share net namespace
Net bool `json:"net,omitempty"`
// share all devices
Dev bool `json:"dev,omitempty"`
// do not run in new session
NoNewSession bool `json:"no_new_session,omitempty"`
// map target user uid to privileged user uid in the user namespace
MapRealUID bool `json:"map_real_uid"`
// direct access to wayland socket
DirectWayland bool `json:"direct_wayland,omitempty"`
// final environment variables
Env map[string]string `json:"env"`
// sandbox host filesystem access
Filesystem []*FilesystemConfig `json:"filesystem"`
// symlinks created inside the sandbox
Link [][2]string `json:"symlink"`
// read-only /etc directory
Etc string `json:"etc,omitempty"`
// automatically set up /etc symlinks
AutoEtc bool `json:"auto_etc"`
// paths to override by mounting tmpfs over them
Override []string `json:"override"`
}
// Bwrap returns the address of the corresponding bwrap.Config to s.
// Note that remaining tmpfs entries must be queued by the caller prior to launch.
func (s *SandboxConfig) Bwrap(os linux.System) (*bwrap.Config, error) {
if s == nil {
return nil, errors.New("nil sandbox config")
}
var uid int
if !s.MapRealUID {
uid = 65534
} else {
uid = os.Geteuid()
}
conf := (&bwrap.Config{
Net: s.Net,
UserNS: s.UserNS,
Hostname: s.Hostname,
Clearenv: true,
SetEnv: s.Env,
/* this is only 4 KiB of memory on a 64-bit system,
permissive defaults on NixOS results in around 100 entries
so this capacity should eliminate copies for most setups */
Filesystem: make([]bwrap.FSBuilder, 0, 256),
NewSession: !s.NoNewSession,
DieWithParent: true,
AsInit: true,
// initialise unconditionally as Once cannot be justified
// for saving such a miniscule amount of memory
Chmod: make(bwrap.ChmodConfig),
}).
SetUID(uid).SetGID(uid).
Procfs("/proc").
Tmpfs(Tmp, 4*1024)
if !s.Dev {
conf.DevTmpfs("/dev").Mqueue("/dev/mqueue")
} else {
conf.Bind("/dev", "/dev", false, true, true)
}
if !s.AutoEtc {
if s.Etc == "" {
conf.Dir("/etc")
} else {
conf.Bind(s.Etc, "/etc")
}
}
// retrieve paths and hide them if they're made available in the sandbox
var hidePaths []string
sc := os.Paths()
hidePaths = append(hidePaths, sc.RuntimePath, sc.SharePath)
_, systemBusAddr := dbus.Address()
if entries, err := dbus.Parse([]byte(systemBusAddr)); err != nil {
return nil, err
} else {
// there is usually only one, do not preallocate
for _, entry := range entries {
if entry.Method != "unix" {
continue
}
for _, pair := range entry.Values {
if pair[0] == "path" {
if path.IsAbs(pair[1]) {
// get parent dir of socket
dir := path.Dir(pair[1])
if dir == "." || dir == "/" {
fmsg.VPrintf("dbus socket %q is in an unusual location", pair[1])
}
hidePaths = append(hidePaths, dir)
} else {
fmsg.VPrintf("dbus socket %q is not absolute", pair[1])
}
}
}
}
}
hidePathMatch := make([]bool, len(hidePaths))
for i := range hidePaths {
if err := evalSymlinks(os, &hidePaths[i]); err != nil {
return nil, err
}
}
for _, c := range s.Filesystem {
if c == nil {
continue
}
if !path.IsAbs(c.Src) {
return nil, fmt.Errorf("src path %q is not absolute", c.Src)
}
dest := c.Dst
if c.Dst == "" {
dest = c.Src
} else if !path.IsAbs(dest) {
return nil, fmt.Errorf("dst path %q is not absolute", dest)
}
srcH := c.Src
if err := evalSymlinks(os, &srcH); err != nil {
return nil, err
}
for i := range hidePaths {
// skip matched entries
if hidePathMatch[i] {
continue
}
if ok, err := deepContainsH(srcH, hidePaths[i]); err != nil {
return nil, err
} else if ok {
hidePathMatch[i] = true
fmsg.VPrintf("hiding paths from %q", c.Src)
}
}
conf.Bind(c.Src, dest, !c.Must, c.Write, c.Device)
}
// hide marked paths before setting up shares
for i, ok := range hidePathMatch {
if ok {
conf.Tmpfs(hidePaths[i], 8192)
}
}
for _, l := range s.Link {
conf.Symlink(l[0], l[1])
}
if s.AutoEtc {
etc := s.Etc
if etc == "" {
etc = "/etc"
}
conf.Bind(etc, Tmp+"/etc")
// link host /etc contents to prevent passwd/group from being overwritten
if d, err := os.ReadDir(etc); err != nil {
return nil, err
} else {
for _, ent := range d {
name := ent.Name()
switch name {
case "passwd":
case "group":
case "mtab":
conf.Symlink("/proc/mounts", "/etc/"+name)
default:
conf.Symlink(Tmp+"/etc/"+name, "/etc/"+name)
}
}
}
}
return conf, nil
}
func evalSymlinks(os linux.System, v *string) error {
if p, err := os.EvalSymlinks(*v); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return err
}
fmsg.VPrintf("path %q does not yet exist", *v)
} else {
*v = p
}
return nil
}

2
fst/shared.go Normal file
View File

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

View File

@ -1,91 +0,0 @@
package fst
import (
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
"git.gensokyo.uk/security/fortify/system"
)
// Template returns a fully populated instance of Config.
func Template() *Config {
return &Config{
ID: "org.chromium.Chromium",
Path: "/run/current-system/sw/bin/chromium",
Args: []string{
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland",
},
Enablements: system.EWayland | system.EDBus | system.EPulse,
SessionBus: &dbus.Config{
See: nil,
Talk: []string{"org.freedesktop.Notifications", "org.freedesktop.FileManager1", "org.freedesktop.ScreenSaver",
"org.freedesktop.secrets", "org.kde.kwalletd5", "org.kde.kwalletd6", "org.gnome.SessionManager"},
Own: []string{"org.chromium.Chromium.*", "org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*"},
Call: map[string]string{"org.freedesktop.portal.*": "*"},
Broadcast: map[string]string{"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"},
Log: false,
Filter: true,
},
SystemBus: &dbus.Config{
See: nil,
Talk: []string{"org.bluez", "org.freedesktop.Avahi", "org.freedesktop.UPower"},
Own: nil,
Call: nil,
Broadcast: nil,
Log: false,
Filter: true,
},
DirectWayland: false,
Username: "chronos",
Shell: "/run/current-system/sw/bin/zsh",
Data: "/var/lib/fortify/u0/org.chromium.Chromium",
Dir: "/data/data/org.chromium.Chromium",
ExtraPerms: []*ExtraPermConfig{
{Path: "/var/lib/fortify/u0", Ensure: true, Execute: true},
{Path: "/var/lib/fortify/u0/org.chromium.Chromium", Read: true, Write: true, Execute: true},
},
Identity: 9,
Groups: []string{"video", "dialout", "plugdev"},
Container: &ContainerConfig{
Hostname: "localhost",
Devel: true,
Userns: true,
Net: true,
Device: true,
Seccomp: seccomp.FilterMultiarch,
Tty: true,
Multiarch: true,
MapRealUID: true,
// example API credentials pulled from Google Chrome
// DO NOT USE THESE IN A REAL BROWSER
Env: map[string]string{
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT",
},
Filesystem: []*FilesystemConfig{
{Src: "/nix/store"},
{Src: "/run/current-system"},
{Src: "/run/opengl-driver"},
{Src: "/var/db/nix-channels"},
{Src: "/var/lib/fortify/u0/org.chromium.Chromium",
Dst: "/data/data/org.chromium.Chromium", Write: true, Must: true},
{Src: "/dev/dri", Device: true},
},
Link: [][2]string{{"/run/user/65534", "/run/user/150"}},
Etc: "/etc",
AutoEtc: true,
Cover: []string{"/var/run/nscd"},
},
}
}

View File

@ -1,140 +0,0 @@
package fst_test
import (
"encoding/json"
"testing"
"git.gensokyo.uk/security/fortify/fst"
)
func TestTemplate(t *testing.T) {
const want = `{
"id": "org.chromium.Chromium",
"path": "/run/current-system/sw/bin/chromium",
"args": [
"chromium",
"--ignore-gpu-blocklist",
"--disable-smooth-scrolling",
"--enable-features=UseOzonePlatform",
"--ozone-platform=wayland"
],
"enablements": 13,
"session_bus": {
"see": null,
"talk": [
"org.freedesktop.Notifications",
"org.freedesktop.FileManager1",
"org.freedesktop.ScreenSaver",
"org.freedesktop.secrets",
"org.kde.kwalletd5",
"org.kde.kwalletd6",
"org.gnome.SessionManager"
],
"own": [
"org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.org.chromium.Chromium.*",
"org.mpris.MediaPlayer2.chromium.*"
],
"call": {
"org.freedesktop.portal.*": "*"
},
"broadcast": {
"org.freedesktop.portal.*": "@/org/freedesktop/portal/*"
},
"filter": true
},
"system_bus": {
"see": null,
"talk": [
"org.bluez",
"org.freedesktop.Avahi",
"org.freedesktop.UPower"
],
"own": null,
"call": null,
"broadcast": null,
"filter": true
},
"username": "chronos",
"shell": "/run/current-system/sw/bin/zsh",
"data": "/var/lib/fortify/u0/org.chromium.Chromium",
"dir": "/data/data/org.chromium.Chromium",
"extra_perms": [
{
"ensure": true,
"path": "/var/lib/fortify/u0",
"x": true
},
{
"path": "/var/lib/fortify/u0/org.chromium.Chromium",
"r": true,
"w": true,
"x": true
}
],
"identity": 9,
"groups": [
"video",
"dialout",
"plugdev"
],
"container": {
"hostname": "localhost",
"seccomp": 32,
"devel": true,
"userns": true,
"net": true,
"tty": true,
"multiarch": true,
"env": {
"GOOGLE_API_KEY": "AIzaSyBHDrl33hwRp4rMQY0ziRbj8K9LPA6vUCY",
"GOOGLE_DEFAULT_CLIENT_ID": "77185425430.apps.googleusercontent.com",
"GOOGLE_DEFAULT_CLIENT_SECRET": "OTJgUOQcT7lO7GsGZq2G4IlT"
},
"map_real_uid": true,
"device": true,
"filesystem": [
{
"src": "/nix/store"
},
{
"src": "/run/current-system"
},
{
"src": "/run/opengl-driver"
},
{
"src": "/var/db/nix-channels"
},
{
"dst": "/data/data/org.chromium.Chromium",
"src": "/var/lib/fortify/u0/org.chromium.Chromium",
"write": true,
"require": true
},
{
"src": "/dev/dri",
"dev": true
}
],
"symlink": [
[
"/run/user/65534",
"/run/user/150"
]
],
"etc": "/etc",
"auto_etc": true,
"cover": [
"/var/run/nscd"
]
}
}`
if p, err := json.MarshalIndent(fst.Template(), "", "\t"); err != nil {
t.Fatalf("cannot marshal: %v", err)
} else if s := string(p); s != want {
t.Fatalf("Template:\n%s\nwant:\n%s",
s, want)
}
}

2
go.mod
View File

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

View File

@ -1,17 +1,38 @@
package helper package helper
import ( import (
"bytes" "errors"
"io" "io"
"syscall" "strings"
) )
type argsWt [][]byte var (
ErrContainsNull = errors.New("argument contains null character")
)
type argsWt []string
// checks whether any element contains the null character
// must be called before args use and args must not be modified after call
func (a argsWt) check() error {
for _, arg := range a {
for _, b := range arg {
if b == '\x00' {
return ErrContainsNull
}
}
}
return nil
}
func (a argsWt) WriteTo(w io.Writer) (int64, error) { func (a argsWt) WriteTo(w io.Writer) (int64, error) {
// assuming already checked
nt := 0 nt := 0
// write null terminated arguments
for _, arg := range a { for _, arg := range a {
n, err := w.Write(arg) n, err := w.Write([]byte(arg + "\x00"))
nt += n nt += n
if err != nil { if err != nil {
@ -23,32 +44,18 @@ func (a argsWt) WriteTo(w io.Writer) (int64, error) {
} }
func (a argsWt) String() string { func (a argsWt) String() string {
return string( return strings.Join(a, " ")
bytes.TrimSuffix(
bytes.ReplaceAll(
bytes.Join(a, nil),
[]byte{0}, []byte{' '},
),
[]byte{' '},
),
)
} }
// NewCheckedArgs returns a checked null-terminated argument writer for a copy of args. // NewCheckedArgs returns a checked argument writer for args.
func NewCheckedArgs(args []string) (wt io.WriterTo, err error) { // Callers must not retain any references to args.
a := make(argsWt, len(args)) func NewCheckedArgs(args []string) (io.WriterTo, error) {
for i, arg := range args { a := argsWt(args)
a[i], err = syscall.ByteSliceFromString(arg) return a, a.check()
if err != nil {
return
}
}
wt = a
return
} }
// MustNewCheckedArgs returns a checked null-terminated argument writer for a copy of args. // MustNewCheckedArgs returns a checked argument writer for args and panics if check fails.
// If s contains a NUL byte this function panics instead of returning an error. // Callers must not retain any references to args.
func MustNewCheckedArgs(args []string) io.WriterTo { func MustNewCheckedArgs(args []string) io.WriterTo {
a, err := NewCheckedArgs(args) a, err := NewCheckedArgs(args)
if err != nil { if err != nil {

View File

@ -4,33 +4,34 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"syscall"
"testing" "testing"
"git.gensokyo.uk/security/fortify/helper" "git.gensokyo.uk/security/fortify/helper"
) )
func TestArgsString(t *testing.T) { func Test_argsFD_String(t *testing.T) {
wantString := strings.Join(wantArgs, " ") wantString := strings.Join(wantArgs, " ")
if got := argsWt.(fmt.Stringer).String(); got != wantString { if got := argsWt.(fmt.Stringer).String(); got != wantString {
t.Errorf("String: %q, want %q", t.Errorf("String(): got %v; want %v",
got, wantString) got, wantString)
} }
} }
func TestNewCheckedArgs(t *testing.T) { func TestNewCheckedArgs(t *testing.T) {
args := []string{"\x00"} args := []string{"\x00"}
if _, err := helper.NewCheckedArgs(args); !errors.Is(err, syscall.EINVAL) { if _, err := helper.NewCheckedArgs(args); !errors.Is(err, helper.ErrContainsNull) {
t.Errorf("NewCheckedArgs: error = %v, wantErr %v", t.Errorf("NewCheckedArgs(%q) error = %v, wantErr %v",
err, syscall.EINVAL) args,
err, helper.ErrContainsNull)
} }
t.Run("must panic", func(t *testing.T) { t.Run("must panic", func(t *testing.T) {
badPayload := []string{"\x00"} badPayload := []string{"\x00"}
defer func() { defer func() {
wantPanic := "invalid argument" wantPanic := "argument contains null character"
if r := recover(); r != wantPanic { if r := recover(); r != wantPanic {
t.Errorf("MustNewCheckedArgs: panic = %v, wantPanic %v", t.Errorf("MustNewCheckedArgs(%q) panic = %v, wantPanic %v",
badPayload,
r, wantPanic) r, wantPanic)
} }
}() }()

151
helper/bwrap.go Normal file
View File

@ -0,0 +1,151 @@
package helper
import (
"errors"
"io"
"os"
"os/exec"
"strconv"
"sync"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/proc"
)
// BubblewrapName is the file name or path to bubblewrap.
var BubblewrapName = "bwrap"
type bubblewrap struct {
// bwrap child file name
name string
// bwrap pipes
control *pipes
// sync pipe
sync *os.File
// returns an array of arguments passed directly
// to the child process spawned by bwrap
argF func(argsFD, statFD int) []string
// pipes received by the child
// nil if no pipes are required
controlPt *pipes
lock sync.RWMutex
*exec.Cmd
}
func (b *bubblewrap) StartNotify(ready chan error) error {
b.lock.Lock()
defer b.lock.Unlock()
if ready != nil && b.controlPt == nil {
panic("attempted to start with status monitoring on a bwrap child initialised without pipes")
}
// Check for doubled Start calls before we defer failure cleanup. If the prior
// call to Start succeeded, we don't want to spuriously close its pipes.
if b.Cmd.Process != nil {
return errors.New("exec: already started")
}
// prepare bwrap pipe and args
if argsFD, _, err := b.control.prepareCmd(b.Cmd); err != nil {
return err
} else {
b.Cmd.Args = append(b.Cmd.Args, "--args", strconv.Itoa(argsFD), "--", b.name)
}
// prepare child args and pipes if enabled
if b.controlPt != nil {
b.controlPt.ready = ready
if argsFD, statFD, err := b.controlPt.prepareCmd(b.Cmd); err != nil {
return err
} else {
b.Cmd.Args = append(b.Cmd.Args, b.argF(argsFD, statFD)...)
}
} else {
b.Cmd.Args = append(b.Cmd.Args, b.argF(-1, -1)...)
}
if ready != nil {
b.Cmd.Env = append(b.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=1")
} else if b.controlPt != nil {
b.Cmd.Env = append(b.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=0")
} else {
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 {
return err
}
// write bwrap args first
if err := b.control.readyWriteArgs(); err != nil {
return err
}
// write child args if enabled
if b.controlPt != nil {
if err := b.controlPt.readyWriteArgs(); err != nil {
return err
}
}
return nil
}
func (b *bubblewrap) Close() error {
if b.controlPt == nil {
panic("attempted to close bwrap child initialised without pipes")
}
return b.controlPt.closeStatus()
}
func (b *bubblewrap) Start() error {
return b.StartNotify(nil)
}
func (b *bubblewrap) Unwrap() *exec.Cmd {
return b.Cmd
}
// MustNewBwrap initialises a new Bwrap instance with wt as the null-terminated argument writer.
// If wt is nil, the child process spawned by bwrap will not get an argument pipe.
// Function argF returns an array of arguments passed directly to the child process.
func MustNewBwrap(conf *bwrap.Config, wt io.WriterTo, name string, argF func(argsFD, statFD int) []string) Helper {
b, err := NewBwrap(conf, wt, name, argF)
if err != nil {
panic(err.Error())
} else {
return b
}
}
// NewBwrap initialises a new Bwrap instance with wt as the null-terminated argument writer.
// If wt is nil, the child process spawned by bwrap will not get an argument pipe.
// Function argF returns an array of arguments passed directly to the child process.
func NewBwrap(conf *bwrap.Config, wt io.WriterTo, name string, argF func(argsFD, statFD int) []string) (Helper, error) {
b := new(bubblewrap)
if args, err := NewCheckedArgs(conf.Args()); err != nil {
return nil, err
} else {
b.control = &pipes{args: args}
}
b.sync = conf.Sync()
b.argF = argF
b.name = name
if wt != nil {
b.controlPt = &pipes{args: wt}
}
b.Cmd = execCommand(BubblewrapName)
return b, nil
}

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

@ -0,0 +1,77 @@
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
}

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

@ -0,0 +1,170 @@
package bwrap
import "os"
/*
Bind binds mount src on host to dest in sandbox.
Bind(src, dest) bind mount host path readonly on sandbox
(--ro-bind SRC DEST).
Bind(src, dest, true) equal to ROBind but ignores non-existent host path
(--ro-bind-try SRC DEST).
Bind(src, dest, false, true) bind mount host path on sandbox.
(--bind SRC DEST).
Bind(src, dest, true, true) equal to Bind but ignores non-existent host path
(--bind-try SRC DEST).
Bind(src, dest, false, true, true) bind mount host path on sandbox, allowing device access
(--dev-bind SRC DEST).
Bind(src, dest, true, true, true) equal to DevBind but ignores non-existent host path
(--dev-bind-try SRC DEST).
*/
func (c *Config) Bind(src, dest string, opts ...bool) *Config {
var (
try bool
write bool
dev bool
)
if len(opts) > 0 {
try = opts[0]
}
if len(opts) > 1 {
write = opts[1]
}
if len(opts) > 2 {
dev = opts[2]
}
if dev {
if try {
c.Filesystem = append(c.Filesystem, &pairF{DevBindTry.Unwrap(), src, dest})
} else {
c.Filesystem = append(c.Filesystem, &pairF{DevBind.Unwrap(), src, dest})
}
return c
} else if write {
if try {
c.Filesystem = append(c.Filesystem, &pairF{BindTry.Unwrap(), src, dest})
} else {
c.Filesystem = append(c.Filesystem, &pairF{Bind.Unwrap(), src, dest})
}
return c
} else {
if try {
c.Filesystem = append(c.Filesystem, &pairF{ROBindTry.Unwrap(), src, dest})
} else {
c.Filesystem = append(c.Filesystem, &pairF{ROBind.Unwrap(), src, dest})
}
return c
}
}
// Dir create dir in sandbox
// (--dir DEST)
func (c *Config) Dir(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{Dir.Unwrap(), dest})
return c
}
// RemountRO remount path as readonly; does not recursively remount
// (--remount-ro DEST)
func (c *Config) RemountRO(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{RemountRO.Unwrap(), dest})
return c
}
// Procfs mount new procfs in sandbox
// (--proc DEST)
func (c *Config) Procfs(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{Procfs.Unwrap(), dest})
return c
}
// DevTmpfs mount new dev in sandbox
// (--dev DEST)
func (c *Config) DevTmpfs(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{DevTmpfs.Unwrap(), dest})
return c
}
// Mqueue mount new mqueue in sandbox
// (--mqueue DEST)
func (c *Config) Mqueue(dest string) *Config {
c.Filesystem = append(c.Filesystem, &stringF{Mqueue.Unwrap(), dest})
return c
}
// Tmpfs mount new tmpfs in sandbox
// (--tmpfs DEST)
func (c *Config) Tmpfs(dest string, size int, perm ...os.FileMode) *Config {
tmpfs := &PermConfig[*TmpfsConfig]{Inner: &TmpfsConfig{Dir: dest}}
if size >= 0 {
tmpfs.Inner.Size = size
}
if len(perm) == 1 {
tmpfs.Mode = &perm[0]
}
c.Filesystem = append(c.Filesystem, tmpfs)
return c
}
// Overlay mount overlayfs on DEST, with writes going to an invisible tmpfs
// (--tmp-overlay DEST)
func (c *Config) Overlay(dest string, src ...string) *Config {
c.Filesystem = append(c.Filesystem, &OverlayConfig{Src: src, Dest: dest})
return c
}
// Join mount overlayfs read-only on DEST
// (--ro-overlay DEST)
func (c *Config) Join(dest string, src ...string) *Config {
c.Filesystem = append(c.Filesystem, &OverlayConfig{Src: src, Dest: dest, Persist: new([2]string)})
return c
}
// Persist mount overlayfs on DEST, with RWSRC as the host path for writes and
// WORKDIR an empty directory on the same filesystem as RWSRC
// (--overlay RWSRC WORKDIR DEST)
func (c *Config) Persist(dest, rwsrc, workdir string, src ...string) *Config {
if rwsrc == "" || workdir == "" {
panic("persist called without required paths")
}
c.Filesystem = append(c.Filesystem, &OverlayConfig{Src: src, Dest: dest, Persist: &[2]string{rwsrc, workdir}})
return c
}
// Symlink create symlink within sandbox
// (--symlink SRC DEST)
func (c *Config) Symlink(src, dest string, perm ...os.FileMode) *Config {
symlink := &PermConfig[SymlinkConfig]{Inner: SymlinkConfig{src, dest}}
if len(perm) == 1 {
symlink.Mode = &perm[0]
}
c.Filesystem = append(c.Filesystem, symlink)
return c
}
// SetUID sets custom uid in the sandbox, requires new user namespace (--uid UID).
func (c *Config) SetUID(uid int) *Config {
if uid >= 0 {
c.UID = &uid
}
return c
}
// SetGID sets custom gid in the sandbox, requires new user namespace (--gid GID).
func (c *Config) SetGID(gid int) *Config {
if gid >= 0 {
c.GID = &gid
}
return c
}
// 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
}

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

@ -0,0 +1,118 @@
package bwrap
import (
"os"
)
type Config struct {
// unshare every namespace we support by default if nil
// (--unshare-all)
Unshare *UnshareConfig `json:"unshare,omitempty"`
// retain the network namespace (can only combine with nil Unshare)
// (--share-net)
Net bool `json:"net"`
// disable further use of user namespaces inside sandbox and fail unless
// further use of user namespace inside sandbox is disabled if false
// (--disable-userns) (--assert-userns-disabled)
UserNS bool `json:"userns"`
// custom uid in the sandbox, requires new user namespace
// (--uid UID)
UID *int `json:"uid,omitempty"`
// custom gid in the sandbox, requires new user namespace
// (--gid GID)
GID *int `json:"gid,omitempty"`
// custom hostname in the sandbox, requires new uts namespace
// (--hostname NAME)
Hostname string `json:"hostname,omitempty"`
// change directory
// (--chdir DIR)
Chdir string `json:"chdir,omitempty"`
// unset all environment variables
// (--clearenv)
Clearenv bool `json:"clearenv"`
// set environment variable
// (--setenv VAR VALUE)
SetEnv map[string]string `json:"setenv,omitempty"`
// unset environment variables
// (--unsetenv VAR)
UnsetEnv []string `json:"unsetenv,omitempty"`
// take a lock on file while sandbox is running
// (--lock-file DEST)
LockFile []string `json:"lock_file,omitempty"`
// ordered filesystem args
Filesystem []FSBuilder `json:"filesystem,omitempty"`
// change permissions (must already exist)
// (--chmod OCTAL PATH)
Chmod ChmodConfig `json:"chmod,omitempty"`
// create a new terminal session
// (--new-session)
NewSession bool `json:"new_session"`
// kills with SIGKILL child process (COMMAND) when bwrap or bwrap's parent dies.
// (--die-with-parent)
DieWithParent bool `json:"die_with_parent"`
// do not install a reaper process with PID=1
// (--as-pid-1)
AsInit bool `json:"as_init"`
// keep this fd open while sandbox is running
// (--sync-fd FD)
sync *os.File
/* unmapped options include:
--unshare-user-try Create new user namespace if possible else continue by skipping it
--unshare-cgroup-try Create new cgroup namespace if possible else continue by skipping it
--userns FD Use this user namespace (cannot combine with --unshare-user)
--userns2 FD After setup switch to this user namespace, only useful with --userns
--pidns FD Use this pid namespace (as parent namespace if using --unshare-pid)
--bind-fd FD DEST Bind open directory or path fd on DEST
--ro-bind-fd FD DEST Bind open directory or path fd read-only on DEST
--exec-label LABEL Exec label for the sandbox
--file-label LABEL File label for temporary sandbox content
--file FD DEST Copy from FD to destination DEST
--bind-data FD DEST Copy from FD to file which is bind-mounted on DEST
--ro-bind-data FD DEST Copy from FD to file which is readonly bind-mounted on DEST
--seccomp FD Load and use seccomp rules from FD (not repeatable)
--add-seccomp-fd FD Load and use seccomp rules from FD (repeatable)
--block-fd FD Block on FD until some data to read is available
--userns-block-fd FD Block on FD until the user namespace is ready
--info-fd FD Write information about the running container to FD
--json-status-fd FD Write container status to FD as multiple JSON documents
--cap-add CAP Add cap CAP when running as privileged user
--cap-drop CAP Drop cap CAP when running as privileged user
among which --args is used internally for passing arguments */
}
// Sync keep this fd open while sandbox is running
// (--sync-fd FD)
func (c *Config) Sync() *os.File {
return c.sync
}
type UnshareConfig struct {
// (--unshare-user)
// create new user namespace
User bool `json:"user"`
// (--unshare-ipc)
// create new ipc namespace
IPC bool `json:"ipc"`
// (--unshare-pid)
// create new pid namespace
PID bool `json:"pid"`
// (--unshare-net)
// create new network namespace
Net bool `json:"net"`
// (--unshare-uts)
// create new uts namespace
UTS bool `json:"uts"`
// (--unshare-cgroup)
// create new cgroup namespace
CGroup bool `json:"cgroup"`
}

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

@ -0,0 +1,255 @@
package bwrap_test
import (
"os"
"slices"
"testing"
"git.gensokyo.uk/security/fortify/helper/bwrap"
)
func TestConfig_Args(t *testing.T) {
testCases := []struct {
name string
conf *bwrap.Config
want []string
}{
{
name: "bind",
conf: (new(bwrap.Config)).
Bind("/etc", "/.fortify/etc").
Bind("/etc", "/.fortify/etc", true).
Bind("/run", "/.fortify/run", false, true).
Bind("/sys/devices", "/.fortify/sys/devices", true, true).
Bind("/dev/dri", "/.fortify/dev/dri", false, true, true).
Bind("/dev/dri", "/.fortify/dev/dri", true, true, true),
want: []string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// Bind("/etc", "/.fortify/etc")
"--ro-bind", "/etc", "/.fortify/etc",
// Bind("/etc", "/.fortify/etc", true)
"--ro-bind-try", "/etc", "/.fortify/etc",
// Bind("/run", "/.fortify/run", false, true)
"--bind", "/run", "/.fortify/run",
// Bind("/sys/devices", "/.fortify/sys/devices", true, true)
"--bind-try", "/sys/devices", "/.fortify/sys/devices",
// Bind("/dev/dri", "/.fortify/dev/dri", false, true, true)
"--dev-bind", "/dev/dri", "/.fortify/dev/dri",
// Bind("/dev/dri", "/.fortify/dev/dri", true, true, true)
"--dev-bind-try", "/dev/dri", "/.fortify/dev/dri",
},
},
{
name: "dir remount-ro proc dev mqueue",
conf: (new(bwrap.Config)).
Dir("/.fortify").
RemountRO("/home").
Procfs("/proc").
DevTmpfs("/dev").
Mqueue("/dev/mqueue"),
want: []string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// Dir("/.fortify")
"--dir", "/.fortify",
// RemountRO("/home")
"--remount-ro", "/home",
// Procfs("/proc")
"--proc", "/proc",
// DevTmpfs("/dev")
"--dev", "/dev",
// Mqueue("/dev/mqueue")
"--mqueue", "/dev/mqueue",
},
},
{
name: "tmpfs",
conf: (new(bwrap.Config)).
Tmpfs("/run/user", 8192).
Tmpfs("/run/dbus", 8192, 0755),
want: []string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// Tmpfs("/run/user", 8192)
"--size", "8192", "--tmpfs", "/run/user",
// Tmpfs("/run/dbus", 8192, 0755)
"--perms", "755", "--size", "8192", "--tmpfs", "/run/dbus",
},
},
{
name: "symlink",
conf: (new(bwrap.Config)).
Symlink("/.fortify/sbin/init", "/sbin/init").
Symlink("/.fortify/sbin/init", "/sbin/init", 0755),
want: []string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// Symlink("/.fortify/sbin/init", "/sbin/init")
"--symlink", "/.fortify/sbin/init", "/sbin/init",
// Symlink("/.fortify/sbin/init", "/sbin/init", 0755)
"--perms", "755", "--symlink", "/.fortify/sbin/init", "/sbin/init",
},
},
{
name: "overlayfs",
conf: (new(bwrap.Config)).
Overlay("/etc", "/etc").
Join("/.fortify/bin", "/bin", "/usr/bin", "/usr/local/bin").
Persist("/nix", "/data/data/org.chromium.Chromium/overlay/rwsrc", "/data/data/org.chromium.Chromium/workdir", "/data/app/org.chromium.Chromium/nix"),
want: []string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// Overlay("/etc", "/etc")
"--overlay-src", "/etc", "--tmp-overlay", "/etc",
// Join("/.fortify/bin", "/bin", "/usr/bin", "/usr/local/bin")
"--overlay-src", "/bin", "--overlay-src", "/usr/bin",
"--overlay-src", "/usr/local/bin", "--ro-overlay", "/.fortify/bin",
// Persist("/nix", "/data/data/org.chromium.Chromium/overlay/rwsrc", "/data/data/org.chromium.Chromium/workdir", "/data/app/org.chromium.Chromium/nix")
"--overlay-src", "/data/app/org.chromium.Chromium/nix",
"--overlay", "/data/data/org.chromium.Chromium/overlay/rwsrc", "/data/data/org.chromium.Chromium/workdir", "/nix",
},
},
{
name: "unshare",
conf: &bwrap.Config{Unshare: &bwrap.UnshareConfig{
User: false,
IPC: false,
PID: false,
Net: false,
UTS: false,
CGroup: false,
}},
want: []string{"--disable-userns", "--assert-userns-disabled"},
},
{
name: "uid gid sync",
conf: (new(bwrap.Config)).
SetUID(1971).
SetGID(100).
SetSync(os.Stdin),
want: []string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// SetUID(1971)
"--uid", "1971",
// SetGID(100)
"--gid", "100",
// SetSync(os.Stdin)
// this is set when the process is created
},
},
{
name: "hostname chdir setenv unsetenv lockfile chmod",
conf: &bwrap.Config{
Hostname: "fortify",
Chdir: "/.fortify",
SetEnv: map[string]string{"FORTIFY_INIT": "/.fortify/sbin/init"},
UnsetEnv: []string{"HOME", "HOST"},
LockFile: []string{"/.fortify/lock"},
Chmod: map[string]os.FileMode{"/.fortify/sbin/init": 0755},
},
want: []string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
// Hostname: "fortify"
"--hostname", "fortify",
// Chdir: "/.fortify"
"--chdir", "/.fortify",
// UnsetEnv: []string{"HOME", "HOST"}
"--unsetenv", "HOME",
"--unsetenv", "HOST",
// LockFile: []string{"/.fortify/lock"},
"--lock-file", "/.fortify/lock",
// SetEnv: map[string]string{"FORTIFY_INIT": "/.fortify/sbin/init"}
"--setenv", "FORTIFY_INIT", "/.fortify/sbin/init",
// Chmod: map[string]os.FileMode{"/.fortify/sbin/init": 0755}
"--chmod", "755", "/.fortify/sbin/init",
},
},
{
name: "xdg-dbus-proxy constraint sample",
conf: (&bwrap.Config{
Unshare: nil,
UserNS: false,
Clearenv: true,
DieWithParent: true,
}).
Symlink("usr/bin", "/bin").
Symlink("var/home", "/home").
Symlink("usr/lib", "/lib").
Symlink("usr/lib64", "/lib64").
Symlink("run/media", "/media").
Symlink("var/mnt", "/mnt").
Symlink("var/opt", "/opt").
Symlink("sysroot/ostree", "/ostree").
Symlink("var/roothome", "/root").
Symlink("usr/sbin", "/sbin").
Symlink("var/srv", "/srv").
Bind("/run", "/run", false, true).
Bind("/tmp", "/tmp", false, true).
Bind("/var", "/var", false, true).
Bind("/run/user/1971/.dbus-proxy/", "/run/user/1971/.dbus-proxy/", false, true).
Bind("/boot", "/boot").
Bind("/dev", "/dev").
Bind("/proc", "/proc").
Bind("/sys", "/sys").
Bind("/sysroot", "/sysroot").
Bind("/usr", "/usr").
Bind("/etc", "/etc"),
want: []string{
"--unshare-all", "--unshare-user",
"--disable-userns", "--assert-userns-disabled",
"--clearenv", "--die-with-parent",
"--symlink", "usr/bin", "/bin",
"--symlink", "var/home", "/home",
"--symlink", "usr/lib", "/lib",
"--symlink", "usr/lib64", "/lib64",
"--symlink", "run/media", "/media",
"--symlink", "var/mnt", "/mnt",
"--symlink", "var/opt", "/opt",
"--symlink", "sysroot/ostree", "/ostree",
"--symlink", "var/roothome", "/root",
"--symlink", "usr/sbin", "/sbin",
"--symlink", "var/srv", "/srv",
"--bind", "/run", "/run",
"--bind", "/tmp", "/tmp",
"--bind", "/var", "/var",
"--bind", "/run/user/1971/.dbus-proxy/", "/run/user/1971/.dbus-proxy/",
"--ro-bind", "/boot", "/boot",
"--ro-bind", "/dev", "/dev",
"--ro-bind", "/proc", "/proc",
"--ro-bind", "/sys", "/sys",
"--ro-bind", "/sysroot", "/sysroot",
"--ro-bind", "/usr", "/usr",
"--ro-bind", "/etc", "/etc",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.conf.Args(); !slices.Equal(got, tc.want) {
t.Errorf("Args() = %#v, want %#v", got, tc.want)
}
})
}
// test persist validation
t.Run("invalid persist", func(t *testing.T) {
defer func() {
wantPanic := "persist called without required paths"
if r := recover(); r != wantPanic {
t.Errorf("Persist() panic = %v; wantPanic %v", r, wantPanic)
}
}()
(new(bwrap.Config)).Persist("/run", "", "")
})
t.Run("sync file", func(t *testing.T) {
if s := (new(bwrap.Config)).SetSync(os.Stdout).Sync(); s != os.Stdout {
t.Errorf("Sync() = %v", s)
}
})
}

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

@ -0,0 +1,223 @@
package bwrap
import (
"encoding/gob"
"os"
"strconv"
)
func init() {
gob.Register(new(PermConfig[SymlinkConfig]))
gob.Register(new(PermConfig[*TmpfsConfig]))
gob.Register(new(OverlayConfig))
}
type PositionalArg int
func (p PositionalArg) Unwrap() string {
return positionalArgs[p]
}
const (
Tmpfs PositionalArg = iota
Symlink
Bind
BindTry
DevBind
DevBindTry
ROBind
ROBindTry
Chmod
Dir
RemountRO
Procfs
DevTmpfs
Mqueue
Perms
Size
OverlaySrc
Overlay
TmpOverlay
ROOverlay
)
var positionalArgs = [...]string{
Tmpfs: "--tmpfs",
Symlink: "--symlink",
Bind: "--bind",
BindTry: "--bind-try",
DevBind: "--dev-bind",
DevBindTry: "--dev-bind-try",
ROBind: "--ro-bind",
ROBindTry: "--ro-bind-try",
Chmod: "--chmod",
Dir: "--dir",
RemountRO: "--remount-ro",
Procfs: "--proc",
DevTmpfs: "--dev",
Mqueue: "--mqueue",
Perms: "--perms",
Size: "--size",
OverlaySrc: "--overlay-src",
Overlay: "--overlay",
TmpOverlay: "--tmp-overlay",
ROOverlay: "--ro-overlay",
}
type PermConfig[T FSBuilder] struct {
// set permissions of next argument
// (--perms OCTAL)
Mode *os.FileMode `json:"mode,omitempty"`
// path to get the new permission
// (--bind-data, --file, etc.)
Inner T `json:"path"`
}
func (p *PermConfig[T]) Path() string {
return p.Inner.Path()
}
func (p *PermConfig[T]) Len() int {
if p.Mode != nil {
return p.Inner.Len() + 2
} else {
return p.Inner.Len()
}
}
func (p *PermConfig[T]) Append(args *[]string) {
if p.Mode != nil {
*args = append(*args, Perms.Unwrap(), strconv.FormatInt(int64(*p.Mode), 8))
}
p.Inner.Append(args)
}
type TmpfsConfig struct {
// set size of tmpfs
// (--size BYTES)
Size int `json:"size,omitempty"`
// mount point of new tmpfs
// (--tmpfs DEST)
Dir string `json:"dir"`
}
func (t *TmpfsConfig) Path() string {
return t.Dir
}
func (t *TmpfsConfig) Len() int {
if t.Size > 0 {
return 4
} else {
return 2
}
}
func (t *TmpfsConfig) Append(args *[]string) {
if t.Size > 0 {
*args = append(*args, Size.Unwrap(), strconv.Itoa(t.Size))
}
*args = append(*args, Tmpfs.Unwrap(), t.Dir)
}
type OverlayConfig struct {
/*
read files from SRC in the following overlay
(--overlay-src SRC)
*/
Src []string `json:"src,omitempty"`
/*
mount overlayfs on DEST, with RWSRC as the host path for writes and
WORKDIR an empty directory on the same filesystem as RWSRC
(--overlay RWSRC WORKDIR DEST)
if nil, mount overlayfs on DEST, with writes going to an invisible tmpfs
(--tmp-overlay DEST)
if either strings are empty, mount overlayfs read-only on DEST
(--ro-overlay DEST)
*/
Persist *[2]string `json:"persist,omitempty"`
/*
--overlay RWSRC WORKDIR DEST
--tmp-overlay DEST
--ro-overlay DEST
*/
Dest string `json:"dest"`
}
func (o *OverlayConfig) Path() string {
return o.Dest
}
func (o *OverlayConfig) Len() int {
// (--tmp-overlay DEST) or (--ro-overlay DEST)
p := 2
// (--overlay RWSRC WORKDIR DEST)
if o.Persist != nil && o.Persist[0] != "" && o.Persist[1] != "" {
p = 4
}
return p + len(o.Src)*2
}
func (o *OverlayConfig) Append(args *[]string) {
// --overlay-src SRC
for _, src := range o.Src {
*args = append(*args, OverlaySrc.Unwrap(), src)
}
if o.Persist != nil {
if o.Persist[0] != "" && o.Persist[1] != "" {
// --overlay RWSRC WORKDIR
*args = append(*args, Overlay.Unwrap(), o.Persist[0], o.Persist[1])
} else {
// --ro-overlay
*args = append(*args, ROOverlay.Unwrap())
}
} else {
// --tmp-overlay
*args = append(*args, TmpOverlay.Unwrap())
}
// DEST
*args = append(*args, o.Dest)
}
type SymlinkConfig [2]string
func (s SymlinkConfig) Path() string {
return s[1]
}
func (s SymlinkConfig) Len() int {
return 3
}
func (s SymlinkConfig) Append(args *[]string) {
*args = append(*args, Symlink.Unwrap(), s[0], s[1])
}
type ChmodConfig map[string]os.FileMode
func (c ChmodConfig) Len() int {
return len(c)
}
func (c ChmodConfig) Append(args *[]string) {
for path, mode := range c {
*args = append(*args, Chmod.Unwrap(), strconv.FormatInt(int64(mode), 8), path)
}
}

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

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

112
helper/bwrap_test.go Normal file
View File

@ -0,0 +1,112 @@
package helper_test
import (
"errors"
"fmt"
"os"
"strings"
"testing"
"git.gensokyo.uk/security/fortify/helper"
"git.gensokyo.uk/security/fortify/helper/bwrap"
)
func TestBwrap(t *testing.T) {
sc := &bwrap.Config{
Unshare: nil,
Net: true,
UserNS: false,
Hostname: "localhost",
Chdir: "/nonexistent",
Clearenv: true,
NewSession: true,
DieWithParent: true,
AsInit: true,
}
t.Run("nonexistent bwrap name", func(t *testing.T) {
bubblewrapName := helper.BubblewrapName
helper.BubblewrapName = "/nonexistent"
t.Cleanup(func() {
helper.BubblewrapName = bubblewrapName
})
h := helper.MustNewBwrap(sc, argsWt, "fortify", argF)
if err := h.Start(); !errors.Is(err, os.ErrNotExist) {
t.Errorf("Start() error = %v, wantErr %v",
err, os.ErrNotExist)
}
})
t.Run("valid new helper nil check", func(t *testing.T) {
if got := helper.MustNewBwrap(sc, argsWt, "fortify", argF); got == nil {
t.Errorf("MustNewBwrap(%#v, %#v, %#v) got nil",
sc, argsWt, "fortify")
return
}
})
t.Run("invalid bwrap config new helper panic", func(t *testing.T) {
defer func() {
wantPanic := "argument contains null character"
if r := recover(); r != wantPanic {
t.Errorf("MustNewBwrap: panic = %q, want %q",
r, wantPanic)
}
}()
helper.MustNewBwrap(&bwrap.Config{Hostname: "\x00"}, nil, "fortify", argF)
})
t.Run("start notify without pipes panic", func(t *testing.T) {
defer func() {
wantPanic := "attempted to start with status monitoring on a bwrap child initialised without pipes"
if r := recover(); r != wantPanic {
t.Errorf("StartNotify: panic = %q, want %q",
r, wantPanic)
}
}()
panic(fmt.Sprintf("unreachable: %v",
helper.MustNewBwrap(sc, nil, "fortify", argF).StartNotify(make(chan error))))
})
t.Run("start without pipes", func(t *testing.T) {
helper.InternalReplaceExecCommand(t)
h := helper.MustNewBwrap(sc, nil, "crash-test-dummy", argFChecked)
cmd := h.Unwrap()
stdout, stderr := new(strings.Builder), new(strings.Builder)
cmd.Stdout, cmd.Stderr = stdout, stderr
t.Run("close without pipes panic", func(t *testing.T) {
defer func() {
wantPanic := "attempted to close bwrap child initialised without pipes"
if r := recover(); r != wantPanic {
t.Errorf("Close: panic = %q, want %q",
r, wantPanic)
}
}()
panic(fmt.Sprintf("unreachable: %v",
h.Close()))
})
if err := h.Start(); err != nil {
t.Errorf("Start() error = %v",
err)
return
}
if err := h.Wait(); err != nil {
t.Errorf("Wait() err = %v stderr = %s",
err, stderr)
}
})
t.Run("implementation compliance", func(t *testing.T) {
testHelper(t, func() helper.Helper { return helper.MustNewBwrap(sc, argsWt, "crash-test-dummy", argF) })
})
}

View File

@ -1,84 +0,0 @@
package helper
import (
"context"
"errors"
"io"
"os"
"os/exec"
"slices"
"sync"
"syscall"
"git.gensokyo.uk/security/fortify/helper/proc"
)
// NewDirect initialises a new direct Helper instance with wt as the null-terminated argument writer.
// Function argF returns an array of arguments passed directly to the child process.
func NewDirect(
ctx context.Context,
name string,
wt io.WriterTo,
stat bool,
argF func(argsFd, statFd int) []string,
cmdF func(cmd *exec.Cmd),
extraFiles []*os.File,
) Helper {
d, args := newHelperCmd(ctx, name, wt, stat, argF, extraFiles)
d.Args = append(d.Args, args...)
if cmdF != nil {
cmdF(d.Cmd)
}
return d
}
func newHelperCmd(
ctx context.Context,
name string,
wt io.WriterTo,
stat bool,
argF func(argsFd, statFd int) []string,
extraFiles []*os.File,
) (cmd *helperCmd, args []string) {
cmd = new(helperCmd)
cmd.helperFiles, args = newHelperFiles(ctx, wt, stat, argF, extraFiles)
cmd.Cmd = exec.CommandContext(ctx, name)
cmd.Cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
cmd.WaitDelay = WaitDelay
return
}
// helperCmd provides a [exec.Cmd] wrapper around helper ipc.
type helperCmd struct {
mu sync.RWMutex
*helperFiles
*exec.Cmd
}
func (h *helperCmd) Start() error {
h.mu.Lock()
defer h.mu.Unlock()
// Check for doubled Start calls before we defer failure cleanup. If the prior
// call to Start succeeded, we don't want to spuriously close its pipes.
if h.Cmd != nil && h.Cmd.Process != nil {
return errors.New("helper: already started")
}
h.Env = slices.Grow(h.Env, 2)
if h.useArgsFd {
h.Env = append(h.Env, FortifyHelper+"=1")
} else {
h.Env = append(h.Env, FortifyHelper+"=0")
}
if h.useStatFd {
h.Env = append(h.Env, FortifyStatus+"=1")
// stat is populated on fulfill
h.Cancel = func() error { return h.stat.Close() }
} else {
h.Env = append(h.Env, FortifyStatus+"=0")
}
return proc.Fulfill(h.helperFiles.ctx, &h.ExtraFiles, h.Cmd.Start, h.files, h.extraFiles)
}

View File

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

View File

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

View File

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

93
helper/direct.go Normal file
View File

@ -0,0 +1,93 @@
package helper
import (
"errors"
"io"
"os/exec"
"sync"
)
// direct wraps *exec.Cmd and manages status and args fd.
// Args is always 3 and status if set is always 4.
type direct struct {
// helper pipes
// cannot be nil
p *pipes
// returns an array of arguments passed directly
// to the helper process
argF func(argsFD, statFD int) []string
lock sync.RWMutex
*exec.Cmd
}
func (h *direct) StartNotify(ready chan error) error {
h.lock.Lock()
defer h.lock.Unlock()
// Check for doubled Start calls before we defer failure cleanup. If the prior
// call to Start succeeded, we don't want to spuriously close its pipes.
if h.Cmd.Process != nil {
return errors.New("exec: already started")
}
h.p.ready = ready
if argsFD, statFD, err := h.p.prepareCmd(h.Cmd); err != nil {
return err
} else {
h.Cmd.Args = append(h.Cmd.Args, h.argF(argsFD, statFD)...)
}
if ready != nil {
h.Cmd.Env = append(h.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=1")
} else {
h.Cmd.Env = append(h.Cmd.Env, FortifyHelper+"=1", FortifyStatus+"=0")
}
if err := h.Cmd.Start(); err != nil {
return err
}
if err := h.p.readyWriteArgs(); err != nil {
return err
}
return nil
}
func (h *direct) Wait() error {
h.lock.RLock()
defer h.lock.RUnlock()
if h.Cmd.Process == nil {
return errors.New("exec: not started")
}
defer h.p.mustClosePipes()
if h.Cmd.ProcessState != nil {
return errors.New("exec: Wait was already called")
}
return h.Cmd.Wait()
}
func (h *direct) Close() error {
return h.p.closeStatus()
}
func (h *direct) Start() error {
return h.StartNotify(nil)
}
func (h *direct) Unwrap() *exec.Cmd {
return h.Cmd
}
// New initialises a new direct Helper instance with wt as the null-terminated argument writer.
// Function argF returns an array of arguments passed directly to the child process.
func New(wt io.WriterTo, name string, argF func(argsFD, statFD int) []string) Helper {
if wt == nil {
panic("attempted to create helper with invalid argument writer")
}
return &direct{p: &pipes{args: wt}, argF: argF, Cmd: execCommand(name)}
}

44
helper/direct_test.go Normal file
View File

@ -0,0 +1,44 @@
package helper_test
import (
"errors"
"os"
"testing"
"git.gensokyo.uk/security/fortify/helper"
)
func TestDirect(t *testing.T) {
t.Run("start non-existent helper path", func(t *testing.T) {
h := helper.New(argsWt, "/nonexistent", argF)
if err := h.Start(); !errors.Is(err, os.ErrNotExist) {
t.Errorf("Start() error = %v, wantErr %v",
err, os.ErrNotExist)
}
})
t.Run("valid new helper nil check", func(t *testing.T) {
if got := helper.New(argsWt, "fortify", argF); got == nil {
t.Errorf("New(%q, %q) got nil",
argsWt, "fortify")
return
}
})
t.Run("invalid new helper panic", func(t *testing.T) {
defer func() {
wantPanic := "attempted to create helper with invalid argument writer"
if r := recover(); r != wantPanic {
t.Errorf("New: panic = %q, want %q",
r, wantPanic)
}
}()
helper.New(nil, "fortify", argF)
})
t.Run("implementation compliance", func(t *testing.T) {
testHelper(t, func() helper.Helper { return helper.New(argsWt, "crash-test-dummy", argF) })
})
}

View File

@ -1,83 +1,36 @@
// Package helper runs external helpers with optional sandboxing. // Package helper runs external helpers with optional sandboxing and manages their status/args pipes.
package helper package helper
import ( import (
"context" "errors"
"fmt" "os/exec"
"io"
"os"
"time"
"git.gensokyo.uk/security/fortify/helper/proc"
) )
var WaitDelay = 2 * time.Second var (
ErrStatusFault = errors.New("generic status pipe fault")
ErrStatusRead = errors.New("unexpected status response")
)
const ( const (
// FortifyHelper is set to 1 when args fd is enabled and 0 otherwise. // FortifyHelper is set for the process launched by Helper.
FortifyHelper = "FORTIFY_HELPER" FortifyHelper = "FORTIFY_HELPER"
// FortifyStatus is set to 1 when stat fd is enabled and 0 otherwise. // FortifyStatus is 1 when sync fd is enabled and 0 otherwise.
FortifyStatus = "FORTIFY_STATUS" FortifyStatus = "FORTIFY_STATUS"
) )
type Helper interface { type Helper interface {
// StartNotify starts the helper process.
// A status pipe is passed to the helper if ready is not nil.
StartNotify(ready chan error) error
// Start starts the helper process. // Start starts the helper process.
Start() error Start() error
// Wait blocks until Helper exits. // Close closes the status pipe.
// If helper is started without the status pipe, Close panics.
Close() error
// Wait calls wait on the child process and cleans up pipes.
Wait() error Wait() error
// Unwrap returns the underlying exec.Cmd instance.
fmt.Stringer Unwrap() *exec.Cmd
} }
func newHelperFiles( var execCommand = exec.Command
ctx context.Context,
wt io.WriterTo,
stat bool,
argF func(argsFd, statFd int) []string,
extraFiles []*os.File,
) (hl *helperFiles, args []string) {
hl = new(helperFiles)
hl.ctx = ctx
hl.useArgsFd = wt != nil
hl.useStatFd = stat
hl.extraFiles = new(proc.ExtraFilesPre)
for _, f := range extraFiles {
_, v := hl.extraFiles.Append()
*v = f
}
argsFd := -1
if hl.useArgsFd {
f := proc.NewWriterTo(wt)
argsFd = int(proc.InitFile(f, hl.extraFiles))
hl.files = append(hl.files, f)
}
statFd := -1
if hl.useStatFd {
f := proc.NewStat(&hl.stat)
statFd = int(proc.InitFile(f, hl.extraFiles))
hl.files = append(hl.files, f)
}
args = argF(argsFd, statFd)
return
}
// helperFiles provides a generic wrapper around helper ipc.
type helperFiles struct {
// whether argsFd is present
useArgsFd bool
// whether statFd is present
useStatFd bool
// closes statFd
stat io.Closer
// deferred extraFiles fulfillment
files []proc.File
// passed through to [proc.Fulfill] and [proc.InitFile]
extraFiles *proc.ExtraFilesPre
ctx context.Context
}

View File

@ -1,11 +1,6 @@
package helper_test package helper_test
import ( import (
"context"
"errors"
"fmt"
"io"
"os"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@ -28,110 +23,145 @@ var (
argsWt = helper.MustNewCheckedArgs(wantArgs) argsWt = helper.MustNewCheckedArgs(wantArgs)
) )
func argF(argsFd, statFd int) []string { func argF(argsFD, statFD int) []string {
if argsFd == -1 { if argsFD == -1 {
panic("invalid args fd") panic("invalid args fd")
} }
return argFChecked(argsFd, statFd) return argFChecked(argsFD, statFD)
} }
func argFChecked(argsFd, statFd int) (args []string) { func argFChecked(argsFD, statFD int) []string {
args = make([]string, 0, 6) if statFD == -1 {
args = append(args, "-test.run=TestHelperStub", "--") return []string{"--args", strconv.Itoa(argsFD)}
if argsFd > -1 { } else {
args = append(args, "--args", strconv.Itoa(argsFd)) return []string{"--args", strconv.Itoa(argsFD), "--fd", strconv.Itoa(statFD)}
} }
if statFd > -1 {
args = append(args, "--fd", strconv.Itoa(statFd))
}
return
} }
// this function tests an implementation of the helper.Helper interface // this function tests an implementation of the helper.Helper interface
func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput func(stdoutP, stderrP *io.Writer), stat bool) helper.Helper) { func testHelper(t *testing.T, createHelper func() helper.Helper) {
oldWaitDelay := helper.WaitDelay helper.InternalReplaceExecCommand(t)
helper.WaitDelay = 16 * time.Second
t.Cleanup(func() { helper.WaitDelay = oldWaitDelay })
t.Run("start helper with status channel and wait", func(t *testing.T) { t.Run("start helper with status channel and wait", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) h := createHelper()
stdout := new(strings.Builder) ready := make(chan error, 1)
h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, os.Stderr }, true) cmd := h.Unwrap()
stdout, stderr := new(strings.Builder), new(strings.Builder)
cmd.Stdout, cmd.Stderr = stdout, stderr
t.Run("wait not yet started helper", func(t *testing.T) { t.Run("wait not yet started helper", func(t *testing.T) {
defer func() { wantErr := "exec: not started"
r := recover() if err := h.Wait(); err != nil && err.Error() != wantErr {
if r == nil { t.Errorf("Wait(%v) error = %v, wantErr %v",
t.Fatalf("Wait did not panic") ready,
}
}()
panic(fmt.Sprintf("unreachable: %v", h.Wait()))
})
t.Log("starting helper stub")
if err := h.Start(); err != nil {
t.Errorf("Start: error = %v", err)
cancel()
return
}
t.Log("cancelling context")
cancel()
t.Run("start already started helper", func(t *testing.T) {
wantErr := "helper: already started"
if err := h.Start(); err != nil && err.Error() != wantErr {
t.Errorf("Start: error = %v, wantErr %v",
err, wantErr) err, wantErr)
return return
} }
}) })
t.Log("waiting on helper") t.Log("starting helper stub")
if err := h.Wait(); !errors.Is(err, context.Canceled) { if err := h.StartNotify(ready); err != nil {
t.Errorf("Wait: error = %v", t.Errorf("StartNotify(%v) error = %v",
ready,
err) err)
return
}
t.Run("start already started helper", func(t *testing.T) {
wantErr := "exec: already started"
if err := h.StartNotify(ready); err != nil && err.Error() != wantErr {
t.Errorf("StartNotify(%v) error = %v, wantErr %v",
ready,
err, wantErr)
return
}
})
t.Log("waiting on status channel with timeout")
select {
case <-time.NewTimer(5 * time.Second).C:
t.Errorf("never got a ready response")
t.Errorf("stdout:\n%s", stdout.String())
t.Errorf("stderr:\n%s", stderr.String())
if err := cmd.Process.Kill(); err != nil {
panic(err.Error())
}
return
case err := <-ready:
if err != nil {
t.Errorf("StartNotify(%v) latent error = %v",
ready,
err)
}
}
t.Log("closing status pipe")
if err := h.Close(); err != nil {
t.Errorf("Close() error = %v",
err)
}
t.Log("waiting on helper")
if err := h.Wait(); err != nil {
t.Errorf("Wait() err = %v stderr = %s",
err, stderr)
} }
t.Run("wait already finalised helper", func(t *testing.T) { t.Run("wait already finalised helper", func(t *testing.T) {
wantErr := "exec: Wait was already called" wantErr := "exec: Wait was already called"
if err := h.Wait(); err != nil && err.Error() != wantErr { if err := h.Wait(); err != nil && err.Error() != wantErr {
t.Errorf("Wait: error = %v, wantErr %v", t.Errorf("Wait(%v) error = %v, wantErr %v",
ready,
err, wantErr) err, wantErr)
return return
} }
}) })
if got := trimStdout(stdout); got != wantPayload { if got := stdout.String(); !strings.HasPrefix(got, wantPayload) {
t.Errorf("Start: stdout = %q, want %q", t.Errorf("StartNotify(%v) stdout = %v, want %v",
ready,
got, wantPayload) got, wantPayload)
} }
}) })
t.Run("start helper and wait", func(t *testing.T) { t.Run("start helper and wait", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) h := createHelper()
defer cancel() cmd := h.Unwrap()
stdout := new(strings.Builder)
h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, os.Stderr }, false) stdout, stderr := new(strings.Builder), new(strings.Builder)
cmd.Stdout, cmd.Stderr = stdout, stderr
if err := h.Start(); err != nil { if err := h.Start(); err != nil {
t.Errorf("Start: error = %v", t.Errorf("Start() error = %v",
err) err)
return return
} }
t.Run("close helper without status pipe", func(t *testing.T) {
defer func() {
wantPanic := "attempted to close helper with no status pipe"
if r := recover(); r != wantPanic {
t.Errorf("Close() panic = %v, wantPanic %v",
r, wantPanic)
}
}()
if err := h.Close(); err != nil {
t.Errorf("Close() error = %v",
err)
return
}
})
if err := h.Wait(); err != nil { if err := h.Wait(); err != nil {
t.Errorf("Wait: error = %v stdout = %q", t.Errorf("Wait() err = %v stderr = %s",
err, stdout) err, stderr)
} }
if got := trimStdout(stdout); got != wantPayload { if got := stdout.String(); !strings.HasPrefix(got, wantPayload) {
t.Errorf("Start: stdout = %q, want %q", t.Errorf("Start() stdout = %v, want %v",
got, wantPayload) got, wantPayload)
} }
}) })
} }
func trimStdout(stdout fmt.Stringer) string {
return strings.TrimPrefix(stdout.String(), "=== RUN TestHelperInit\n")
}

149
helper/pipe.go Normal file
View File

@ -0,0 +1,149 @@
package helper
import (
"errors"
"io"
"os"
"os/exec"
"git.gensokyo.uk/security/fortify/internal/proc"
)
type pipes struct {
args io.WriterTo
statP [2]*os.File
argsP [2]*os.File
ready chan error
cmd *exec.Cmd
}
func (p *pipes) pipe() error {
if p.statP[0] != nil || p.statP[1] != nil ||
p.argsP[0] != nil || p.argsP[1] != nil {
panic("attempted to pipe twice")
}
if p.args == nil {
panic("attempted to pipe without args")
}
// create pipes
if pr, pw, err := os.Pipe(); err != nil {
return err
} else {
p.argsP[0], p.argsP[1] = pr, pw
}
// create status pipes if ready signal is requested
if p.ready != nil {
if pr, pw, err := os.Pipe(); err != nil {
return err
} else {
p.statP[0], p.statP[1] = pr, pw
}
}
return nil
}
// 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) {
argsFd, statFd = -1, -1
if err = p.pipe(); err != nil {
return
}
// save a reference of cmd for future use
p.cmd = cmd
argsFd = int(proc.ExtraFile(cmd, p.argsP[0]))
if p.ready != nil {
statFd = int(proc.ExtraFile(cmd, p.statP[1]))
}
return
}
func (p *pipes) readyWriteArgs() error {
statsP, argsP := p.statP[0], p.argsP[1]
// write arguments and close args pipe
if _, err := p.args.WriteTo(argsP); err != nil {
if err1 := p.cmd.Process.Kill(); err1 != nil {
// should be unreachable
panic(err1.Error())
}
return err
} else {
if err = argsP.Close(); err != nil {
if err1 := p.cmd.Process.Kill(); err1 != nil {
// should be unreachable
panic(err1.Error())
}
return err
}
}
if p.ready != nil {
// monitor stat pipe
go func() {
n, err := statsP.Read(make([]byte, 1))
switch n {
case -1:
if err1 := p.cmd.Process.Kill(); err1 != nil {
// should be unreachable
panic(err1.Error())
}
// ensure error is not nil
if err == nil {
err = ErrStatusFault
}
p.ready <- err
case 0:
// ensure error is not nil
if err == nil {
err = ErrStatusRead
}
p.ready <- err
case 1:
p.ready <- nil
default:
panic("unreachable") // unexpected read count
}
}()
}
return nil
}
func (p *pipes) mustClosePipes() {
if err := p.argsP[0].Close(); err != nil && !errors.Is(err, os.ErrClosed) {
// unreachable
panic(err.Error())
}
if err := p.argsP[1].Close(); err != nil && !errors.Is(err, os.ErrClosed) {
// unreachable
panic(err.Error())
}
if p.ready != nil {
if err := p.statP[0].Close(); err != nil && !errors.Is(err, os.ErrClosed) {
// unreachable
panic(err.Error())
}
if err := p.statP[1].Close(); err != nil && !errors.Is(err, os.ErrClosed) {
// unreachable
panic(err.Error())
}
}
}
func (p *pipes) closeStatus() error {
if p.ready == nil {
panic("attempted to close helper with no status pipe")
}
return p.statP[0].Close()
}

42
helper/pipe_test.go Normal file
View File

@ -0,0 +1,42 @@
package helper
import (
"testing"
)
func Test_pipes_pipe_mustClosePipes(t *testing.T) {
p := new(pipes)
t.Run("pipe without args", func(t *testing.T) {
defer func() {
wantPanic := "attempted to pipe without args"
if r := recover(); r != wantPanic {
t.Errorf("pipe() panic = %v, wantPanic %v",
r, wantPanic)
}
}()
_ = p.pipe()
})
p.args = MustNewCheckedArgs(make([]string, 0))
t.Run("obtain pipes", func(t *testing.T) {
if err := p.pipe(); err != nil {
t.Errorf("pipe() error = %v",
err)
return
}
})
t.Run("pipe twice", func(t *testing.T) {
defer func() {
wantPanic := "attempted to pipe twice"
if r := recover(); r != wantPanic {
t.Errorf("pipe() panic = %v, wantPanic %v",
r, wantPanic)
}
}()
_ = p.pipe()
})
p.mustClosePipes()
}

View File

@ -1,155 +0,0 @@
package proc
import (
"context"
"os"
"os/exec"
"sync/atomic"
"syscall"
"time"
)
var FulfillmentTimeout = 2 * time.Second
// A File is an extra file with deferred initialisation.
type File interface {
// Init initialises File state. Init must not be called more than once.
Init(fd uintptr, v **os.File) uintptr
// Fd returns the fd value set on initialisation.
Fd() uintptr
// ErrCount returns count of error values emitted during fulfillment.
ErrCount() int
// Fulfill is called prior to process creation and must populate its corresponding file address.
// Calls to dispatchErr must match the return value of ErrCount.
// Fulfill must not be called more than once.
Fulfill(ctx context.Context, dispatchErr func(error)) error
}
// ExtraFilesPre is a linked list storing addresses of [os.File].
type ExtraFilesPre struct {
n *ExtraFilesPre
v *os.File
}
// Append grows the list by one entry and returns an address of the address of [os.File] stored in the new entry.
func (f *ExtraFilesPre) Append() (uintptr, **os.File) { return f.append(3) }
// Files returns a slice pointing to a continuous segment of memory containing all addresses stored in f in order.
func (f *ExtraFilesPre) Files() []*os.File { return f.copy(make([]*os.File, 0, f.len())) }
func (f *ExtraFilesPre) append(i uintptr) (uintptr, **os.File) {
if f.n == nil {
f.n = new(ExtraFilesPre)
return i, &f.v
}
return f.n.append(i + 1)
}
func (f *ExtraFilesPre) len() uintptr {
if f == nil {
return 0
}
return f.n.len() + 1
}
func (f *ExtraFilesPre) copy(e []*os.File) []*os.File {
if f == nil {
// the public methods ensure the first call is never nil;
// the last element is unused, slice it off here
return e[:len(e)-1]
}
return f.n.copy(append(e, f.v))
}
// Fulfill calls the [File.Fulfill] method on all files, starts cmd and blocks until all fulfillment completes.
func Fulfill(ctx context.Context,
v *[]*os.File, start func() error,
files []File, extraFiles *ExtraFilesPre,
) (err error) {
var ecs int
for _, o := range files {
ecs += o.ErrCount()
}
ec := make(chan error, ecs)
c, cancel := context.WithTimeout(ctx, FulfillmentTimeout)
defer cancel()
for _, f := range files {
err = f.Fulfill(c, makeDispatchErr(f, ec))
if err != nil {
return
}
}
*v = extraFiles.Files()
if err = start(); err != nil {
return
}
for ecs > 0 {
select {
case err = <-ec:
ecs--
if err != nil {
break
}
case <-ctx.Done():
err = syscall.ECANCELED
break
}
}
return
}
// InitFile initialises f as part of the slice extraFiles points to,
// and returns its final fd value.
func InitFile(f File, extraFiles *ExtraFilesPre) (fd uintptr) { return f.Init(extraFiles.Append()) }
// BaseFile implements the Init method of the File interface and provides indirect access to extra file state.
type BaseFile struct {
fd uintptr
v **os.File
}
func (f *BaseFile) Init(fd uintptr, v **os.File) uintptr {
if v == nil || fd < 3 {
panic("invalid extra file initial state")
}
if f.v != nil {
panic("extra file initialised twice")
}
f.fd, f.v = fd, v
return fd
}
func (f *BaseFile) Fd() uintptr {
if f.v == nil {
panic("use of uninitialised extra file")
}
return f.fd
}
func (f *BaseFile) Set(v *os.File) {
*f.v = v // runtime guards against use before init
}
func makeDispatchErr(f File, ec chan<- error) func(error) {
c := new(atomic.Int32)
c.Store(int32(f.ErrCount()))
return func(err error) {
if c.Add(-1) < 0 {
panic("unexpected error dispatches")
}
ec <- err
}
}
func ExtraFile(cmd *exec.Cmd, f *os.File) (fd uintptr) {
return ExtraFileSlice(&cmd.ExtraFiles, f)
}
func ExtraFileSlice(extraFiles *[]*os.File, f *os.File) (fd uintptr) {
// ExtraFiles: If non-nil, entry i becomes file descriptor 3+i.
fd = uintptr(3 + len(*extraFiles))
*extraFiles = append(*extraFiles, f)
return
}

View File

@ -1,110 +0,0 @@
package proc
import (
"context"
"errors"
"io"
"os"
"runtime"
)
// NewWriterTo returns a [File] that receives content from wt on fulfillment.
func NewWriterTo(wt io.WriterTo) File { return &writeToFile{wt: wt} }
// writeToFile exports the read end of a pipe with data written by an [io.WriterTo].
type writeToFile struct {
wt io.WriterTo
BaseFile
}
func (f *writeToFile) ErrCount() int { return 3 }
func (f *writeToFile) Fulfill(ctx context.Context, dispatchErr func(error)) error {
r, w, err := os.Pipe()
if err != nil {
return err
}
f.Set(r)
done := make(chan struct{})
go func() {
_, err = f.wt.WriteTo(w)
dispatchErr(err)
dispatchErr(w.Close())
close(done)
runtime.KeepAlive(r)
}()
go func() {
select {
case <-done:
dispatchErr(nil)
case <-ctx.Done():
dispatchErr(w.Close()) // this aborts WriteTo with file already closed
runtime.KeepAlive(r)
}
}()
return nil
}
// NewStat returns a [File] implementing the behaviour
// of the receiving end of xdg-dbus-proxy stat fd.
func NewStat(s *io.Closer) File { return &statFile{s: s} }
var (
ErrStatFault = errors.New("generic stat fd fault")
ErrStatRead = errors.New("unexpected stat behaviour")
)
// statFile implements xdg-dbus-proxy stat fd behaviour.
type statFile struct {
s *io.Closer
BaseFile
}
func (f *statFile) ErrCount() int { return 2 }
func (f *statFile) Fulfill(ctx context.Context, dispatchErr func(error)) error {
r, w, err := os.Pipe()
if err != nil {
return err
}
f.Set(w)
done := make(chan struct{})
go func() {
defer close(done)
var n int
n, err = r.Read(make([]byte, 1))
switch n {
case -1:
if err == nil {
err = ErrStatFault
}
dispatchErr(err)
case 0:
if err == nil {
err = ErrStatRead
}
dispatchErr(err)
case 1:
dispatchErr(err)
default:
panic("unreachable")
}
runtime.KeepAlive(w)
}()
go func() {
select {
case <-done:
dispatchErr(nil)
case <-ctx.Done():
dispatchErr(r.Close()) // this aborts Read with file already closed
runtime.KeepAlive(w)
}
}()
// this gets closed by the caller
*f.s = r
return nil
}

View File

@ -2,80 +2,95 @@ package helper
import ( import (
"flag" "flag"
"fmt"
"io" "io"
"os" "os"
"os/exec"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"testing"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/fmsg"
) )
// InternalHelperStub is an internal function but exported because it is cross-package; // InternalChildStub is an internal function but exported because it is cross-package;
// it is part of the implementation of the helper stub. // it is part of the implementation of the helper stub.
func InternalHelperStub() { func InternalChildStub() {
// this test mocks the helper process // this test mocks the helper process
var ap, sp string if os.Getenv(FortifyHelper) != "1" ||
if v, ok := os.LookupEnv(FortifyHelper); !ok { os.Getenv(FortifyStatus) == "-1" { // this indicates the stub is being invoked as a bwrap child without pipes
return return
} else {
ap = v
}
if v, ok := os.LookupEnv(FortifyStatus); !ok {
panic(FortifyStatus)
} else {
sp = v
} }
genericStub(flagRestoreFiles(3, ap, sp)) argsFD := flag.Int("args", -1, "")
statFD := flag.Int("fd", -1, "")
_ = flag.CommandLine.Parse(os.Args[4:])
os.Exit(0) switch os.Args[3] {
} case "bwrap":
bwrapStub(argsFD, statFD)
func newFile(fd int, name, p string) *os.File {
present := false
switch p {
case "0":
case "1":
present = true
default: default:
panic(fmt.Sprintf("%s fd has unexpected presence value %q", name, p)) genericStub(argsFD, statFD)
} }
f := os.NewFile(uintptr(fd), name) fmsg.Exit(0)
if !present && f != nil {
panic(fmt.Sprintf("%s fd set but not present", name))
}
if present && f == nil {
panic(fmt.Sprintf("%s fd preset but unset", name))
}
return f
} }
func flagRestoreFiles(offset int, ap, sp string) (argsFile, statFile *os.File) { // InternalReplaceExecCommand is an internal function but exported because it is cross-package;
argsFd := flag.Int("args", -1, "") // it is part of the implementation of the helper stub.
statFd := flag.Int("fd", -1, "") func InternalReplaceExecCommand(t *testing.T) {
_ = flag.CommandLine.Parse(os.Args[offset:]) t.Cleanup(func() {
argsFile = newFile(*argsFd, "args", ap) execCommand = exec.Command
statFile = newFile(*statFd, "stat", sp) })
return
// replace execCommand to have the resulting *exec.Cmd launch TestHelperChildStub
execCommand = func(name string, arg ...string) *exec.Cmd {
// pass through nonexistent path
if name == "/nonexistent" && len(arg) == 0 {
return exec.Command(name)
}
return exec.Command(os.Args[0], append([]string{"-test.run=TestHelperChildStub", "--", name}, arg...)...)
}
} }
func genericStub(argsFile, statFile *os.File) { func genericStub(argsFD, statFD *int) {
if argsFile != nil { // simulate args pipe behaviour
// this output is checked by parent func() {
if _, err := io.Copy(os.Stdout, argsFile); err != nil { if *argsFD == -1 {
panic("attempted to start helper without passing args pipe fd")
}
f := os.NewFile(uintptr(*argsFD), "|0")
if f == nil {
panic("attempted to start helper without args pipe")
}
if _, err := io.Copy(os.Stdout, f); err != nil {
panic("cannot read args: " + err.Error()) panic("cannot read args: " + err.Error())
} }
} }()
var wait chan struct{}
// simulate status pipe behaviour // simulate status pipe behaviour
if statFile != nil { if os.Getenv(FortifyStatus) == "1" {
if _, err := statFile.Write([]byte{'x'}); err != nil { if *statFD == -1 {
panic("attempted to start helper with status reporting without passing status pipe fd")
}
wait = make(chan struct{})
go func() {
f := os.NewFile(uintptr(*statFD), "|1")
if f == nil {
panic("attempted to start with status reporting without status pipe")
}
if _, err := f.Write([]byte{'x'}); err != nil {
panic("cannot write to status pipe: " + err.Error()) panic("cannot write to status pipe: " + err.Error())
} }
done := make(chan struct{})
go func() {
// wait for status pipe close // wait for status pipe close
var epoll int var epoll int
if fd, err := syscall.EpollCreate1(0); err != nil { if fd, err := syscall.EpollCreate1(0); err != nil {
@ -88,7 +103,7 @@ func genericStub(argsFile, statFile *os.File) {
}() }()
epoll = fd epoll = fd
} }
if err := syscall.EpollCtl(epoll, syscall.EPOLL_CTL_ADD, int(statFile.Fd()), &syscall.EpollEvent{}); err != nil { if err := syscall.EpollCtl(epoll, syscall.EPOLL_CTL_ADD, int(f.Fd()), &syscall.EpollEvent{}); err != nil {
panic("cannot add status pipe to epoll: " + err.Error()) panic("cannot add status pipe to epoll: " + err.Error())
} }
events := make([]syscall.EpollEvent, 1) events := make([]syscall.EpollEvent, 1)
@ -99,8 +114,62 @@ func genericStub(argsFile, statFile *os.File) {
panic(strconv.Itoa(int(events[0].Events))) panic(strconv.Itoa(int(events[0].Events)))
} }
close(done) close(wait)
}() }()
<-done }
if wait != nil {
<-wait
}
}
func bwrapStub(argsFD, statFD *int) {
// the bwrap launcher does not ever launch with sync fd
if *statFD != -1 {
panic("attempted to launch bwrap with status monitoring")
}
// test args pipe behaviour
func() {
if *argsFD == -1 {
panic("attempted to start bwrap without passing args pipe fd")
}
f := os.NewFile(uintptr(*argsFD), "|0")
if f == nil {
panic("attempted to start helper without args pipe")
}
got, want := new(strings.Builder), new(strings.Builder)
if _, err := io.Copy(got, f); err != nil {
panic("cannot read args: " + err.Error())
}
// hardcoded bwrap configuration used by test
if _, err := MustNewCheckedArgs((&bwrap.Config{
Unshare: nil,
Net: true,
UserNS: false,
Hostname: "localhost",
Chdir: "/nonexistent",
Clearenv: true,
NewSession: true,
DieWithParent: true,
AsInit: true,
}).Args()).WriteTo(want); err != nil {
panic("cannot read want: " + err.Error())
}
if len(flag.CommandLine.Args()) > 0 && flag.CommandLine.Args()[0] == "crash-test-dummy" && got.String() != want.String() {
panic("bad bwrap args\ngot: " + got.String() + "\nwant: " + want.String())
}
}()
if err := syscall.Exec(
os.Args[0],
append([]string{os.Args[0], "-test.run=TestHelperChildStub", "--"}, flag.CommandLine.Args()...),
os.Environ()); err != nil {
panic("cannot start general stub: " + err.Error())
} }
} }

View File

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

View File

@ -1,59 +1,97 @@
// Package app defines the generic [App] interface.
package app package app
import ( import (
"syscall" "sync"
"time" "sync/atomic"
"git.gensokyo.uk/security/fortify/cmd/fshim/ipc/shim"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/linux"
) )
type App interface { type App interface {
// ID returns a copy of [ID] held by App. // ID returns a copy of App's unique ID.
ID() ID ID() fst.ID
// Start sets up the system and starts the App.
// Seal determines the outcome of config as a [SealedApp]. Start() error
// The value of config might be overwritten and must not be used again. // Wait waits for App's process to exit and reverts system setup.
Seal(config *fst.Config) (SealedApp, error) Wait() (int, error)
// WaitErr returns error returned by the underlying wait syscall.
WaitErr() error
Seal(config *fst.Config) error
String() string String() string
} }
type SealedApp interface { type app struct {
// Run commits sealed system setup and starts the app process. // single-use config reference
Run(rs *RunState) error ct *appCt
// application unique identifier
id *fst.ID
// operating system interface
os linux.System
// shim process manager
shim *shim.Shim
// child process related information
seal *appSeal
// error returned waiting for process
waitErr error
lock sync.RWMutex
} }
// RunState stores the outcome of a call to [SealedApp.Run]. func (a *app) ID() fst.ID {
type RunState struct { return *a.id
// Time is the exact point in time where the process was created.
// Location must be set to UTC.
//
// Time is nil if no process was ever created.
Time *time.Time
// RevertErr is stored by the deferred revert call.
RevertErr error
// WaitErr is the generic error value created by the standard library.
WaitErr error
syscall.WaitStatus
} }
// SetStart stores the current time in [RunState] once. func (a *app) String() string {
func (rs *RunState) SetStart() { if a == nil {
if rs.Time != nil { return "(invalid fortified app)"
panic("attempted to store time twice")
} }
now := time.Now().UTC()
rs.Time = &now a.lock.RLock()
defer a.lock.RUnlock()
if a.shim != nil {
return a.shim.String()
}
if a.seal != nil {
return "(sealed fortified app as uid " + a.seal.sys.user.us + ")"
}
return "(unsealed fortified app)"
} }
// Paths contains environment-dependent paths used by fortify. func (a *app) WaitErr() error {
type Paths struct { return a.waitErr
// path to shared directory (usually `/tmp/fortify.%d`) }
SharePath string `json:"share_path"`
// XDG_RUNTIME_DIR value (usually `/run/user/%d`) func New(os linux.System) (App, error) {
RuntimePath string `json:"runtime_path"` a := new(app)
// application runtime directory (usually `/run/user/%d/fortify`) a.id = new(fst.ID)
RunDirPath string `json:"run_dir_path"` 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

@ -0,0 +1,224 @@
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": fst.Tmp + "/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(fst.Tmp, 4096).
DevTmpfs("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin").
Bind("/usr/bin", "/usr/bin").
Bind("/nix/store", "/nix/store").
Bind("/run/current-system", "/run/current-system").
Bind("/sys/block", "/sys/block", true).
Bind("/sys/bus", "/sys/bus", true).
Bind("/sys/class", "/sys/class", true).
Bind("/sys/dev", "/sys/dev", true).
Bind("/sys/devices", "/sys/devices", true).
Bind("/run/opengl-driver", "/run/opengl-driver").
Bind("/dev/dri", "/dev/dri", true, true, true).
Bind("/etc", fst.Tmp+"/etc").
Symlink(fst.Tmp+"/etc/alsa", "/etc/alsa").
Symlink(fst.Tmp+"/etc/bashrc", "/etc/bashrc").
Symlink(fst.Tmp+"/etc/binfmt.d", "/etc/binfmt.d").
Symlink(fst.Tmp+"/etc/dbus-1", "/etc/dbus-1").
Symlink(fst.Tmp+"/etc/default", "/etc/default").
Symlink(fst.Tmp+"/etc/ethertypes", "/etc/ethertypes").
Symlink(fst.Tmp+"/etc/fonts", "/etc/fonts").
Symlink(fst.Tmp+"/etc/fstab", "/etc/fstab").
Symlink(fst.Tmp+"/etc/fuse.conf", "/etc/fuse.conf").
Symlink(fst.Tmp+"/etc/host.conf", "/etc/host.conf").
Symlink(fst.Tmp+"/etc/hostid", "/etc/hostid").
Symlink(fst.Tmp+"/etc/hostname", "/etc/hostname").
Symlink(fst.Tmp+"/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Symlink(fst.Tmp+"/etc/hosts", "/etc/hosts").
Symlink(fst.Tmp+"/etc/inputrc", "/etc/inputrc").
Symlink(fst.Tmp+"/etc/ipsec.d", "/etc/ipsec.d").
Symlink(fst.Tmp+"/etc/issue", "/etc/issue").
Symlink(fst.Tmp+"/etc/kbd", "/etc/kbd").
Symlink(fst.Tmp+"/etc/libblockdev", "/etc/libblockdev").
Symlink(fst.Tmp+"/etc/locale.conf", "/etc/locale.conf").
Symlink(fst.Tmp+"/etc/localtime", "/etc/localtime").
Symlink(fst.Tmp+"/etc/login.defs", "/etc/login.defs").
Symlink(fst.Tmp+"/etc/lsb-release", "/etc/lsb-release").
Symlink(fst.Tmp+"/etc/lvm", "/etc/lvm").
Symlink(fst.Tmp+"/etc/machine-id", "/etc/machine-id").
Symlink(fst.Tmp+"/etc/man_db.conf", "/etc/man_db.conf").
Symlink(fst.Tmp+"/etc/modprobe.d", "/etc/modprobe.d").
Symlink(fst.Tmp+"/etc/modules-load.d", "/etc/modules-load.d").
Symlink("/proc/mounts", "/etc/mtab").
Symlink(fst.Tmp+"/etc/nanorc", "/etc/nanorc").
Symlink(fst.Tmp+"/etc/netgroup", "/etc/netgroup").
Symlink(fst.Tmp+"/etc/NetworkManager", "/etc/NetworkManager").
Symlink(fst.Tmp+"/etc/nix", "/etc/nix").
Symlink(fst.Tmp+"/etc/nixos", "/etc/nixos").
Symlink(fst.Tmp+"/etc/NIXOS", "/etc/NIXOS").
Symlink(fst.Tmp+"/etc/nscd.conf", "/etc/nscd.conf").
Symlink(fst.Tmp+"/etc/nsswitch.conf", "/etc/nsswitch.conf").
Symlink(fst.Tmp+"/etc/opensnitchd", "/etc/opensnitchd").
Symlink(fst.Tmp+"/etc/os-release", "/etc/os-release").
Symlink(fst.Tmp+"/etc/pam", "/etc/pam").
Symlink(fst.Tmp+"/etc/pam.d", "/etc/pam.d").
Symlink(fst.Tmp+"/etc/pipewire", "/etc/pipewire").
Symlink(fst.Tmp+"/etc/pki", "/etc/pki").
Symlink(fst.Tmp+"/etc/polkit-1", "/etc/polkit-1").
Symlink(fst.Tmp+"/etc/profile", "/etc/profile").
Symlink(fst.Tmp+"/etc/protocols", "/etc/protocols").
Symlink(fst.Tmp+"/etc/qemu", "/etc/qemu").
Symlink(fst.Tmp+"/etc/resolv.conf", "/etc/resolv.conf").
Symlink(fst.Tmp+"/etc/resolvconf.conf", "/etc/resolvconf.conf").
Symlink(fst.Tmp+"/etc/rpc", "/etc/rpc").
Symlink(fst.Tmp+"/etc/samba", "/etc/samba").
Symlink(fst.Tmp+"/etc/sddm.conf", "/etc/sddm.conf").
Symlink(fst.Tmp+"/etc/secureboot", "/etc/secureboot").
Symlink(fst.Tmp+"/etc/services", "/etc/services").
Symlink(fst.Tmp+"/etc/set-environment", "/etc/set-environment").
Symlink(fst.Tmp+"/etc/shadow", "/etc/shadow").
Symlink(fst.Tmp+"/etc/shells", "/etc/shells").
Symlink(fst.Tmp+"/etc/ssh", "/etc/ssh").
Symlink(fst.Tmp+"/etc/ssl", "/etc/ssl").
Symlink(fst.Tmp+"/etc/static", "/etc/static").
Symlink(fst.Tmp+"/etc/subgid", "/etc/subgid").
Symlink(fst.Tmp+"/etc/subuid", "/etc/subuid").
Symlink(fst.Tmp+"/etc/sudoers", "/etc/sudoers").
Symlink(fst.Tmp+"/etc/sysctl.d", "/etc/sysctl.d").
Symlink(fst.Tmp+"/etc/systemd", "/etc/systemd").
Symlink(fst.Tmp+"/etc/terminfo", "/etc/terminfo").
Symlink(fst.Tmp+"/etc/tmpfiles.d", "/etc/tmpfiles.d").
Symlink(fst.Tmp+"/etc/udev", "/etc/udev").
Symlink(fst.Tmp+"/etc/udisks2", "/etc/udisks2").
Symlink(fst.Tmp+"/etc/UPower", "/etc/UPower").
Symlink(fst.Tmp+"/etc/vconsole.conf", "/etc/vconsole.conf").
Symlink(fst.Tmp+"/etc/X11", "/etc/X11").
Symlink(fst.Tmp+"/etc/zfs", "/etc/zfs").
Symlink(fst.Tmp+"/etc/zinputrc", "/etc/zinputrc").
Symlink(fst.Tmp+"/etc/zoneinfo", "/etc/zoneinfo").
Symlink(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Symlink(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Symlink(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/1", "/tmp", false, true).
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", fst.Tmp+"/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),
},
}

390
internal/app/app_pd_test.go Normal file
View File

@ -0,0 +1,390 @@
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(fst.Tmp, 4096).
DevTmpfs("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", false, true).
Bind("/boot", "/boot", false, true).
Bind("/home", "/home", false, true).
Bind("/lib", "/lib", false, true).
Bind("/lib64", "/lib64", false, true).
Bind("/nix", "/nix", false, true).
Bind("/root", "/root", false, true).
Bind("/run", "/run", false, true).
Bind("/srv", "/srv", false, true).
Bind("/sys", "/sys", false, true).
Bind("/usr", "/usr", false, true).
Bind("/var", "/var", false, true).
Bind("/dev/kvm", "/dev/kvm", true, true, true).
Tmpfs("/run/user/1971", 8192).
Tmpfs("/run/dbus", 8192).
Bind("/etc", fst.Tmp+"/etc").
Symlink(fst.Tmp+"/etc/alsa", "/etc/alsa").
Symlink(fst.Tmp+"/etc/bashrc", "/etc/bashrc").
Symlink(fst.Tmp+"/etc/binfmt.d", "/etc/binfmt.d").
Symlink(fst.Tmp+"/etc/dbus-1", "/etc/dbus-1").
Symlink(fst.Tmp+"/etc/default", "/etc/default").
Symlink(fst.Tmp+"/etc/ethertypes", "/etc/ethertypes").
Symlink(fst.Tmp+"/etc/fonts", "/etc/fonts").
Symlink(fst.Tmp+"/etc/fstab", "/etc/fstab").
Symlink(fst.Tmp+"/etc/fuse.conf", "/etc/fuse.conf").
Symlink(fst.Tmp+"/etc/host.conf", "/etc/host.conf").
Symlink(fst.Tmp+"/etc/hostid", "/etc/hostid").
Symlink(fst.Tmp+"/etc/hostname", "/etc/hostname").
Symlink(fst.Tmp+"/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Symlink(fst.Tmp+"/etc/hosts", "/etc/hosts").
Symlink(fst.Tmp+"/etc/inputrc", "/etc/inputrc").
Symlink(fst.Tmp+"/etc/ipsec.d", "/etc/ipsec.d").
Symlink(fst.Tmp+"/etc/issue", "/etc/issue").
Symlink(fst.Tmp+"/etc/kbd", "/etc/kbd").
Symlink(fst.Tmp+"/etc/libblockdev", "/etc/libblockdev").
Symlink(fst.Tmp+"/etc/locale.conf", "/etc/locale.conf").
Symlink(fst.Tmp+"/etc/localtime", "/etc/localtime").
Symlink(fst.Tmp+"/etc/login.defs", "/etc/login.defs").
Symlink(fst.Tmp+"/etc/lsb-release", "/etc/lsb-release").
Symlink(fst.Tmp+"/etc/lvm", "/etc/lvm").
Symlink(fst.Tmp+"/etc/machine-id", "/etc/machine-id").
Symlink(fst.Tmp+"/etc/man_db.conf", "/etc/man_db.conf").
Symlink(fst.Tmp+"/etc/modprobe.d", "/etc/modprobe.d").
Symlink(fst.Tmp+"/etc/modules-load.d", "/etc/modules-load.d").
Symlink("/proc/mounts", "/etc/mtab").
Symlink(fst.Tmp+"/etc/nanorc", "/etc/nanorc").
Symlink(fst.Tmp+"/etc/netgroup", "/etc/netgroup").
Symlink(fst.Tmp+"/etc/NetworkManager", "/etc/NetworkManager").
Symlink(fst.Tmp+"/etc/nix", "/etc/nix").
Symlink(fst.Tmp+"/etc/nixos", "/etc/nixos").
Symlink(fst.Tmp+"/etc/NIXOS", "/etc/NIXOS").
Symlink(fst.Tmp+"/etc/nscd.conf", "/etc/nscd.conf").
Symlink(fst.Tmp+"/etc/nsswitch.conf", "/etc/nsswitch.conf").
Symlink(fst.Tmp+"/etc/opensnitchd", "/etc/opensnitchd").
Symlink(fst.Tmp+"/etc/os-release", "/etc/os-release").
Symlink(fst.Tmp+"/etc/pam", "/etc/pam").
Symlink(fst.Tmp+"/etc/pam.d", "/etc/pam.d").
Symlink(fst.Tmp+"/etc/pipewire", "/etc/pipewire").
Symlink(fst.Tmp+"/etc/pki", "/etc/pki").
Symlink(fst.Tmp+"/etc/polkit-1", "/etc/polkit-1").
Symlink(fst.Tmp+"/etc/profile", "/etc/profile").
Symlink(fst.Tmp+"/etc/protocols", "/etc/protocols").
Symlink(fst.Tmp+"/etc/qemu", "/etc/qemu").
Symlink(fst.Tmp+"/etc/resolv.conf", "/etc/resolv.conf").
Symlink(fst.Tmp+"/etc/resolvconf.conf", "/etc/resolvconf.conf").
Symlink(fst.Tmp+"/etc/rpc", "/etc/rpc").
Symlink(fst.Tmp+"/etc/samba", "/etc/samba").
Symlink(fst.Tmp+"/etc/sddm.conf", "/etc/sddm.conf").
Symlink(fst.Tmp+"/etc/secureboot", "/etc/secureboot").
Symlink(fst.Tmp+"/etc/services", "/etc/services").
Symlink(fst.Tmp+"/etc/set-environment", "/etc/set-environment").
Symlink(fst.Tmp+"/etc/shadow", "/etc/shadow").
Symlink(fst.Tmp+"/etc/shells", "/etc/shells").
Symlink(fst.Tmp+"/etc/ssh", "/etc/ssh").
Symlink(fst.Tmp+"/etc/ssl", "/etc/ssl").
Symlink(fst.Tmp+"/etc/static", "/etc/static").
Symlink(fst.Tmp+"/etc/subgid", "/etc/subgid").
Symlink(fst.Tmp+"/etc/subuid", "/etc/subuid").
Symlink(fst.Tmp+"/etc/sudoers", "/etc/sudoers").
Symlink(fst.Tmp+"/etc/sysctl.d", "/etc/sysctl.d").
Symlink(fst.Tmp+"/etc/systemd", "/etc/systemd").
Symlink(fst.Tmp+"/etc/terminfo", "/etc/terminfo").
Symlink(fst.Tmp+"/etc/tmpfiles.d", "/etc/tmpfiles.d").
Symlink(fst.Tmp+"/etc/udev", "/etc/udev").
Symlink(fst.Tmp+"/etc/udisks2", "/etc/udisks2").
Symlink(fst.Tmp+"/etc/UPower", "/etc/UPower").
Symlink(fst.Tmp+"/etc/vconsole.conf", "/etc/vconsole.conf").
Symlink(fst.Tmp+"/etc/X11", "/etc/X11").
Symlink(fst.Tmp+"/etc/zfs", "/etc/zfs").
Symlink(fst.Tmp+"/etc/zinputrc", "/etc/zinputrc").
Symlink(fst.Tmp+"/etc/zoneinfo", "/etc/zoneinfo").
Symlink(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Symlink(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Symlink(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/0", "/tmp", false, true).
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": fst.Tmp + "/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(fst.Tmp, 4096).
DevTmpfs("/dev").Mqueue("/dev/mqueue").
Bind("/bin", "/bin", false, true).
Bind("/boot", "/boot", false, true).
Bind("/home", "/home", false, true).
Bind("/lib", "/lib", false, true).
Bind("/lib64", "/lib64", false, true).
Bind("/nix", "/nix", false, true).
Bind("/root", "/root", false, true).
Bind("/run", "/run", false, true).
Bind("/srv", "/srv", false, true).
Bind("/sys", "/sys", false, true).
Bind("/usr", "/usr", false, true).
Bind("/var", "/var", false, true).
Bind("/dev/dri", "/dev/dri", true, true, true).
Bind("/dev/kvm", "/dev/kvm", true, true, true).
Tmpfs("/run/user/1971", 8192).
Tmpfs("/run/dbus", 8192).
Bind("/etc", fst.Tmp+"/etc").
Symlink(fst.Tmp+"/etc/alsa", "/etc/alsa").
Symlink(fst.Tmp+"/etc/bashrc", "/etc/bashrc").
Symlink(fst.Tmp+"/etc/binfmt.d", "/etc/binfmt.d").
Symlink(fst.Tmp+"/etc/dbus-1", "/etc/dbus-1").
Symlink(fst.Tmp+"/etc/default", "/etc/default").
Symlink(fst.Tmp+"/etc/ethertypes", "/etc/ethertypes").
Symlink(fst.Tmp+"/etc/fonts", "/etc/fonts").
Symlink(fst.Tmp+"/etc/fstab", "/etc/fstab").
Symlink(fst.Tmp+"/etc/fuse.conf", "/etc/fuse.conf").
Symlink(fst.Tmp+"/etc/host.conf", "/etc/host.conf").
Symlink(fst.Tmp+"/etc/hostid", "/etc/hostid").
Symlink(fst.Tmp+"/etc/hostname", "/etc/hostname").
Symlink(fst.Tmp+"/etc/hostname.CHECKSUM", "/etc/hostname.CHECKSUM").
Symlink(fst.Tmp+"/etc/hosts", "/etc/hosts").
Symlink(fst.Tmp+"/etc/inputrc", "/etc/inputrc").
Symlink(fst.Tmp+"/etc/ipsec.d", "/etc/ipsec.d").
Symlink(fst.Tmp+"/etc/issue", "/etc/issue").
Symlink(fst.Tmp+"/etc/kbd", "/etc/kbd").
Symlink(fst.Tmp+"/etc/libblockdev", "/etc/libblockdev").
Symlink(fst.Tmp+"/etc/locale.conf", "/etc/locale.conf").
Symlink(fst.Tmp+"/etc/localtime", "/etc/localtime").
Symlink(fst.Tmp+"/etc/login.defs", "/etc/login.defs").
Symlink(fst.Tmp+"/etc/lsb-release", "/etc/lsb-release").
Symlink(fst.Tmp+"/etc/lvm", "/etc/lvm").
Symlink(fst.Tmp+"/etc/machine-id", "/etc/machine-id").
Symlink(fst.Tmp+"/etc/man_db.conf", "/etc/man_db.conf").
Symlink(fst.Tmp+"/etc/modprobe.d", "/etc/modprobe.d").
Symlink(fst.Tmp+"/etc/modules-load.d", "/etc/modules-load.d").
Symlink("/proc/mounts", "/etc/mtab").
Symlink(fst.Tmp+"/etc/nanorc", "/etc/nanorc").
Symlink(fst.Tmp+"/etc/netgroup", "/etc/netgroup").
Symlink(fst.Tmp+"/etc/NetworkManager", "/etc/NetworkManager").
Symlink(fst.Tmp+"/etc/nix", "/etc/nix").
Symlink(fst.Tmp+"/etc/nixos", "/etc/nixos").
Symlink(fst.Tmp+"/etc/NIXOS", "/etc/NIXOS").
Symlink(fst.Tmp+"/etc/nscd.conf", "/etc/nscd.conf").
Symlink(fst.Tmp+"/etc/nsswitch.conf", "/etc/nsswitch.conf").
Symlink(fst.Tmp+"/etc/opensnitchd", "/etc/opensnitchd").
Symlink(fst.Tmp+"/etc/os-release", "/etc/os-release").
Symlink(fst.Tmp+"/etc/pam", "/etc/pam").
Symlink(fst.Tmp+"/etc/pam.d", "/etc/pam.d").
Symlink(fst.Tmp+"/etc/pipewire", "/etc/pipewire").
Symlink(fst.Tmp+"/etc/pki", "/etc/pki").
Symlink(fst.Tmp+"/etc/polkit-1", "/etc/polkit-1").
Symlink(fst.Tmp+"/etc/profile", "/etc/profile").
Symlink(fst.Tmp+"/etc/protocols", "/etc/protocols").
Symlink(fst.Tmp+"/etc/qemu", "/etc/qemu").
Symlink(fst.Tmp+"/etc/resolv.conf", "/etc/resolv.conf").
Symlink(fst.Tmp+"/etc/resolvconf.conf", "/etc/resolvconf.conf").
Symlink(fst.Tmp+"/etc/rpc", "/etc/rpc").
Symlink(fst.Tmp+"/etc/samba", "/etc/samba").
Symlink(fst.Tmp+"/etc/sddm.conf", "/etc/sddm.conf").
Symlink(fst.Tmp+"/etc/secureboot", "/etc/secureboot").
Symlink(fst.Tmp+"/etc/services", "/etc/services").
Symlink(fst.Tmp+"/etc/set-environment", "/etc/set-environment").
Symlink(fst.Tmp+"/etc/shadow", "/etc/shadow").
Symlink(fst.Tmp+"/etc/shells", "/etc/shells").
Symlink(fst.Tmp+"/etc/ssh", "/etc/ssh").
Symlink(fst.Tmp+"/etc/ssl", "/etc/ssl").
Symlink(fst.Tmp+"/etc/static", "/etc/static").
Symlink(fst.Tmp+"/etc/subgid", "/etc/subgid").
Symlink(fst.Tmp+"/etc/subuid", "/etc/subuid").
Symlink(fst.Tmp+"/etc/sudoers", "/etc/sudoers").
Symlink(fst.Tmp+"/etc/sysctl.d", "/etc/sysctl.d").
Symlink(fst.Tmp+"/etc/systemd", "/etc/systemd").
Symlink(fst.Tmp+"/etc/terminfo", "/etc/terminfo").
Symlink(fst.Tmp+"/etc/tmpfiles.d", "/etc/tmpfiles.d").
Symlink(fst.Tmp+"/etc/udev", "/etc/udev").
Symlink(fst.Tmp+"/etc/udisks2", "/etc/udisks2").
Symlink(fst.Tmp+"/etc/UPower", "/etc/UPower").
Symlink(fst.Tmp+"/etc/vconsole.conf", "/etc/vconsole.conf").
Symlink(fst.Tmp+"/etc/X11", "/etc/X11").
Symlink(fst.Tmp+"/etc/zfs", "/etc/zfs").
Symlink(fst.Tmp+"/etc/zinputrc", "/etc/zinputrc").
Symlink(fst.Tmp+"/etc/zoneinfo", "/etc/zoneinfo").
Symlink(fst.Tmp+"/etc/zprofile", "/etc/zprofile").
Symlink(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Symlink(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Bind("/tmp/fortify.1971/tmpdir/9", "/tmp", false, true).
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", fst.Tmp+"/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,13 +1,12 @@
package setuid_test package app_test
import ( import (
"fmt" "fmt"
"io/fs" "io/fs"
"log"
"os/user" "os/user"
"strconv" "strconv"
"git.gensokyo.uk/security/fortify/internal/app" "git.gensokyo.uk/security/fortify/internal/linux"
) )
// fs methods are not implemented using a real FS // fs methods are not implemented using a real FS
@ -17,16 +16,9 @@ type stubNixOS struct {
usernameErr map[string]error usernameErr map[string]error
} }
func (s *stubNixOS) Getuid() int { return 1971 } func (s *stubNixOS) Geteuid() int {
func (s *stubNixOS) Getgid() int { return 100 } return 1971
func (s *stubNixOS) TempDir() string { return "/tmp" } }
func (s *stubNixOS) MustExecutable() string { return "/run/wrappers/bin/fortify" }
func (s *stubNixOS) Exit(code int) { panic("called exit on stub with code " + strconv.Itoa(code)) }
func (s *stubNixOS) EvalSymlinks(path string) (string, error) { return path, nil }
func (s *stubNixOS) Uid(aid int) (int, error) { return 1000000 + 0*10000 + aid, nil }
func (s *stubNixOS) Println(v ...any) { log.Println(v...) }
func (s *stubNixOS) Printf(format string, v ...any) { log.Printf(format, v...) }
func (s *stubNixOS) LookupEnv(key string) (string, bool) { func (s *stubNixOS) LookupEnv(key string) (string, bool) {
switch key { switch key {
@ -47,6 +39,10 @@ func (s *stubNixOS) LookupEnv(key string) (string, bool) {
} }
} }
func (s *stubNixOS) TempDir() string {
return "/tmp"
}
func (s *stubNixOS) LookPath(file string) (string, error) { func (s *stubNixOS) LookPath(file string) (string, error) {
if s.lookPathErr != nil { if s.lookPathErr != nil {
if err, ok := s.lookPathErr[file]; ok { if err, ok := s.lookPathErr[file]; ok {
@ -55,13 +51,19 @@ func (s *stubNixOS) LookPath(file string) (string, error) {
} }
switch file { switch file {
case "zsh": case "sudo":
return "/run/current-system/sw/bin/zsh", nil return "/run/wrappers/bin/sudo", nil
case "machinectl":
return "/home/ophestra/.nix-profile/bin/machinectl", nil
default: default:
panic(fmt.Sprintf("attempted to look up unexpected executable %q", file)) panic(fmt.Sprintf("attempted to look up unexpected executable %q", file))
} }
} }
func (s *stubNixOS) Executable() (string, error) {
return "/home/ophestra/.nix-profile/bin/fortify", nil
}
func (s *stubNixOS) LookupGroup(name string) (*user.Group, error) { func (s *stubNixOS) LookupGroup(name string) (*user.Group, error) {
switch name { switch name {
case "video": case "video":
@ -125,10 +127,26 @@ func (s *stubNixOS) Open(name string) (fs.File, error) {
} }
} }
func (s *stubNixOS) Paths() app.Paths { func (s *stubNixOS) EvalSymlinks(path string) (string, error) {
return app.Paths{ return path, nil
}
func (s *stubNixOS) Exit(code int) {
panic("called exit on stub with code " + strconv.Itoa(code))
}
func (s *stubNixOS) Paths() linux.Paths {
return linux.Paths{
SharePath: "/tmp/fortify.1971", SharePath: "/tmp/fortify.1971",
RuntimePath: "/run/user/1971", RuntimePath: "/run/user/1971",
RunDirPath: "/run/user/1971/fortify", 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,4 +1,4 @@
package setuid_test package app_test
import ( import (
"encoding/json" "encoding/json"
@ -8,20 +8,19 @@ import (
"time" "time"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/bwrap"
"git.gensokyo.uk/security/fortify/internal/app" "git.gensokyo.uk/security/fortify/internal/app"
"git.gensokyo.uk/security/fortify/internal/app/internal/setuid" "git.gensokyo.uk/security/fortify/internal/linux"
"git.gensokyo.uk/security/fortify/internal/sys" "git.gensokyo.uk/security/fortify/internal/system"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/system"
) )
type sealTestCase struct { type sealTestCase struct {
name string name string
os sys.State os linux.System
config *fst.Config config *fst.Config
id app.ID id fst.ID
wantSys *system.I wantSys *system.I
wantContainer *sandbox.Params wantBwrap *bwrap.Config
} }
func TestApp(t *testing.T) { func TestApp(t *testing.T) {
@ -29,22 +28,18 @@ func TestApp(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
a := setuid.NewWithID(tc.id, tc.os) a := app.NewWithID(tc.id, tc.os)
var (
gotSys *system.I
gotContainer *sandbox.Params
)
if !t.Run("seal", func(t *testing.T) { if !t.Run("seal", func(t *testing.T) {
if sa, err := a.Seal(tc.config); err != nil { if err := a.Seal(tc.config); err != nil {
t.Errorf("Seal: error = %v", err) t.Errorf("Seal: error = %v", err)
return
} else {
gotSys, gotContainer = setuid.AppIParams(a, sa)
} }
}) { }) {
return return
} }
gotSys, gotBwrap := app.AppSystemBwrap(a)
t.Run("compare sys", func(t *testing.T) { t.Run("compare sys", func(t *testing.T) {
if !gotSys.Equal(tc.wantSys) { if !gotSys.Equal(tc.wantSys) {
t.Errorf("Seal: sys = %#v, want %#v", t.Errorf("Seal: sys = %#v, want %#v",
@ -52,10 +47,10 @@ func TestApp(t *testing.T) {
} }
}) })
t.Run("compare params", func(t *testing.T) { t.Run("compare bwrap", func(t *testing.T) {
if !reflect.DeepEqual(gotContainer, tc.wantContainer) { if !reflect.DeepEqual(gotBwrap, tc.wantBwrap) {
t.Errorf("seal: params =\n%s\n, want\n%s", t.Errorf("seal: bwrap =\n%s\n, want\n%s",
mustMarshal(gotContainer), mustMarshal(tc.wantContainer)) mustMarshal(gotBwrap), mustMarshal(tc.wantBwrap))
} }
}) })
}) })

View File

@ -0,0 +1,20 @@
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
}

View File

@ -1,189 +0,0 @@
package common
import (
"errors"
"fmt"
"io/fs"
"maps"
"path"
"syscall"
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/internal/sys"
"git.gensokyo.uk/security/fortify/sandbox"
"git.gensokyo.uk/security/fortify/sandbox/seccomp"
)
// in practice there should be less than 30 entries added by the runtime;
// allocating slightly more as a margin for future expansion
const preallocateOpsCount = 1 << 5
// NewContainer initialises [sandbox.Params] via [fst.ContainerConfig].
// Note that remaining container setup must be queued by the caller.
func NewContainer(s *fst.ContainerConfig, os sys.State, uid, gid *int) (*sandbox.Params, map[string]string, error) {
if s == nil {
return nil, nil, syscall.EBADE
}
container := &sandbox.Params{
Hostname: s.Hostname,
Seccomp: s.Seccomp,
}
{
ops := make(sandbox.Ops, 0, preallocateOpsCount+len(s.Filesystem)+len(s.Link)+len(s.Cover))
container.Ops = &ops
}
if s.Multiarch {
container.Seccomp |= seccomp.FilterMultiarch
}
if s.Devel {
container.Flags |= sandbox.FAllowDevel
}
if s.Userns {
container.Flags |= sandbox.FAllowUserns
}
if s.Net {
container.Flags |= sandbox.FAllowNet
}
if s.Tty {
container.Flags |= sandbox.FAllowTTY
}
if s.MapRealUID {
/* some programs fail to connect to dbus session running as a different uid
so this workaround is introduced to map priv-side caller uid in container */
container.Uid = os.Getuid()
*uid = container.Uid
container.Gid = os.Getgid()
*gid = container.Gid
} else {
*uid = sandbox.OverflowUid()
*gid = sandbox.OverflowGid()
}
container.
Proc("/proc").
Tmpfs(fst.Tmp, 1<<12, 0755)
if !s.Device {
container.Dev("/dev").Mqueue("/dev/mqueue")
} else {
container.Bind("/dev", "/dev", sandbox.BindWritable|sandbox.BindDevice)
}
/* retrieve paths and hide them if they're made available in the sandbox;
this feature tries to improve user experience of permissive defaults, and
to warn about issues in custom configuration; it is NOT a security feature
and should not be treated as such, ALWAYS be careful with what you bind */
var hidePaths []string
sc := os.Paths()
hidePaths = append(hidePaths, sc.RuntimePath, sc.SharePath)
_, systemBusAddr := dbus.Address()
if entries, err := dbus.Parse([]byte(systemBusAddr)); err != nil {
return nil, nil, err
} else {
// there is usually only one, do not preallocate
for _, entry := range entries {
if entry.Method != "unix" {
continue
}
for _, pair := range entry.Values {
if pair[0] == "path" {
if path.IsAbs(pair[1]) {
// get parent dir of socket
dir := path.Dir(pair[1])
if dir == "." || dir == "/" {
os.Printf("dbus socket %q is in an unusual location", pair[1])
}
hidePaths = append(hidePaths, dir)
} else {
os.Printf("dbus socket %q is not absolute", pair[1])
}
}
}
}
}
hidePathMatch := make([]bool, len(hidePaths))
for i := range hidePaths {
if err := evalSymlinks(os, &hidePaths[i]); err != nil {
return nil, nil, err
}
}
for _, c := range s.Filesystem {
if c == nil {
continue
}
if !path.IsAbs(c.Src) {
return nil, nil, fmt.Errorf("src path %q is not absolute", c.Src)
}
dest := c.Dst
if c.Dst == "" {
dest = c.Src
} else if !path.IsAbs(dest) {
return nil, nil, fmt.Errorf("dst path %q is not absolute", dest)
}
srcH := c.Src
if err := evalSymlinks(os, &srcH); err != nil {
return nil, nil, err
}
for i := range hidePaths {
// skip matched entries
if hidePathMatch[i] {
continue
}
if ok, err := deepContainsH(srcH, hidePaths[i]); err != nil {
return nil, nil, err
} else if ok {
hidePathMatch[i] = true
os.Printf("hiding paths from %q", c.Src)
}
}
var flags int
if c.Write {
flags |= sandbox.BindWritable
}
if c.Device {
flags |= sandbox.BindDevice | sandbox.BindWritable
}
if !c.Must {
flags |= sandbox.BindOptional
}
container.Bind(c.Src, dest, flags)
}
// cover matched paths
for i, ok := range hidePathMatch {
if ok {
container.Tmpfs(hidePaths[i], 1<<13, 0755)
}
}
for _, l := range s.Link {
container.Link(l[0], l[1])
}
return container, maps.Clone(s.Env), nil
}
func evalSymlinks(os sys.State, v *string) error {
if p, err := os.EvalSymlinks(*v); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return err
}
os.Printf("path %q does not yet exist", *v)
} else {
*v = p
}
return nil
}

View File

@ -1,17 +0,0 @@
package instance
import (
"syscall"
"git.gensokyo.uk/security/fortify/internal/app"
"git.gensokyo.uk/security/fortify/internal/app/internal/setuid"
)
func PrintRunStateErr(whence int, rs *app.RunState, runErr error) (code int) {
switch whence {
case ISetuid:
return setuid.PrintRunStateErr(rs, runErr)
default:
panic(syscall.EINVAL)
}
}

View File

@ -1,33 +0,0 @@
// Package instance exposes cross-package implementation details and provides constructors for builtin implementations.
package instance
import (
"context"
"log"
"syscall"
"git.gensokyo.uk/security/fortify/internal/app"
"git.gensokyo.uk/security/fortify/internal/app/internal/setuid"
"git.gensokyo.uk/security/fortify/internal/sys"
)
const (
ISetuid = iota
)
func New(whence int, ctx context.Context, os sys.State) (app.App, error) {
switch whence {
case ISetuid:
return setuid.New(ctx, os)
default:
return nil, syscall.EINVAL
}
}
func MustNew(whence int, ctx context.Context, os sys.State) app.App {
a, err := New(whence, ctx, os)
if err != nil {
log.Fatalf("cannot create app: %v", err)
}
return a
}

View File

@ -1,6 +0,0 @@
package instance
import "git.gensokyo.uk/security/fortify/internal/app/internal/setuid"
// ShimMain is the main function of the shim process and runs as the unconstrained target user.
func ShimMain() { setuid.ShimMain() }

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