Compare commits

..

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

46 changed files with 431 additions and 1073 deletions

View File

@ -22,57 +22,6 @@ jobs:
path: result/*
retention-days: 1
race:
name: Fortify (race detector)
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.race
- name: Upload test output
uses: actions/upload-artifact@v3
with:
name: "fortify-race-vm-output"
path: result/*
retention-days: 1
sandbox:
name: Sandbox
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:
name: "sandbox-vm-output"
path: result/*
retention-days: 1
sandbox-race:
name: Sandbox (race detector)
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-race
- 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
@ -90,14 +39,29 @@ jobs:
path: result/*
retention-days: 1
race:
name: Data race detector
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.race
- name: Upload test output
uses: actions/upload-artifact@v3
with:
name: "fortify-race-vm-output"
path: result/*
retention-days: 1
check:
name: Flake checks
needs:
- fortify
- race
- sandbox
- sandbox-race
- fpkg
- race
runs-on: nix
steps:
- name: Checkout

View File

@ -73,7 +73,6 @@ func (app *appInfo) toFst(pathSet *appPathSet, argv []string, flagDropShell bool
Username: "fortify",
Inner: path.Join("/data/data", app.ID),
Outer: pathSet.homeDir,
Shell: shellPath,
Sandbox: &fst.SandboxConfig{
Hostname: formatHostname(app.Name),
Devel: app.Devel,

View File

@ -34,7 +34,6 @@ func withNixDaemon(
Username: "fortify",
Inner: path.Join("/data/data", app.ID),
Outer: pathSet.homeDir,
Shell: shellPath,
Sandbox: &fst.SandboxConfig{
Hostname: formatHostname(app.Name) + "-" + action,
Userns: true, // nix sandbox requires userns
@ -73,7 +72,6 @@ func withCacheDir(
Username: "nixos",
Inner: path.Join("/data/data", app.ID, "cache"),
Outer: pathSet.cacheDir, // this also ensures cacheDir via shim
Shell: shellPath,
Sandbox: &fst.SandboxConfig{
Hostname: formatHostname(app.Name) + "-" + action,
Seccomp: seccomp.FlagMultiarch,

View File

@ -8,7 +8,6 @@ import (
"os"
"os/exec"
"strings"
"syscall"
"testing"
"time"
@ -72,7 +71,7 @@ func TestProxy_Seal(t *testing.T) {
for id, tc := range testCasePairs() {
t.Run("create seal for "+id, func(t *testing.T) {
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",
tc[0].c, tc[1].c,
err, tc[0].wantErr)

12
flake.lock generated
View File

@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1742655702,
"narHash": "sha256-jbqlw4sPArFtNtA1s3kLg7/A4fzP4GLk9bGbtUJg0JQ=",
"lastModified": 1742234739,
"narHash": "sha256-zFL6zsf/5OztR1NSNQF33dvS1fL/BzVUjabZq4qrtY4=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "0948aeedc296f964140d9429223c7e4a0702a1ff",
"rev": "f6af7280a3390e65c2ad8fd059cdc303426cbd59",
"type": "github"
},
"original": {
@ -23,11 +23,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1743231893,
"narHash": "sha256-tpJsHMUPEhEnzySoQxx7+kA+KUtgWqvlcUBqROYNNt0=",
"lastModified": 1742512142,
"narHash": "sha256-8XfURTDxOm6+33swQJu/hx6xw1Tznl8vJJN5HwVqckg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c570c1f5304493cafe133b8d843c7c1c4a10d3a6",
"rev": "7105ae3957700a9646cc4b766f5815b23ed0c682",
"type": "github"
},
"original": {

View File

@ -58,19 +58,12 @@
in
{
fortify = callPackage ./test { inherit system self; };
fpkg = callPackage ./cmd/fpkg/test { inherit system self; };
race = callPackage ./test {
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 ${./.}

View File

@ -35,8 +35,6 @@ type ConfinementConfig struct {
Inner string `json:"home_inner"`
// home directory in init namespace
Outer string `json:"home"`
// absolute path to shell, empty for host shell
Shell string `json:"shell,omitempty"`
// abstract sandbox configuration
Sandbox *SandboxConfig `json:"sandbox"`
// extra acl ops, runs after everything else
@ -99,7 +97,6 @@ func Template() *Config {
Username: "chronos",
Outer: "/var/lib/persist/home/org.chromium.Chromium",
Inner: "/var/lib/fortify",
Shell: "/run/current-system/sw/bin/zsh",
Sandbox: &SandboxConfig{
Hostname: "localhost",
Devel: true,

View File

@ -97,10 +97,6 @@ func (s *SandboxConfig) ToContainer(sys SandboxSys, uid, gid *int) (*sandbox.Par
Seccomp: s.Seccomp,
}
if s.Multiarch {
container.Seccomp |= seccomp.FlagMultiarch
}
/* 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 */

View File

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

View File

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

View File

@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"testing"
@ -56,8 +55,8 @@ func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput f
t.Run("start helper with status channel and wait", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
stdout := new(strings.Builder)
h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, os.Stderr }, true)
stdout, stderr := new(strings.Builder), new(strings.Builder)
h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, stderr }, true)
t.Run("wait not yet started helper", func(t *testing.T) {
defer func() {
@ -89,8 +88,8 @@ func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput f
t.Log("waiting on helper")
if err := h.Wait(); !errors.Is(err, context.Canceled) {
t.Errorf("Wait: error = %v",
err)
t.Errorf("Wait() err = %v stderr = %s",
err, stderr)
}
t.Run("wait already finalised helper", func(t *testing.T) {
@ -102,8 +101,8 @@ func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput f
}
})
if got := trimStdout(stdout); got != wantPayload {
t.Errorf("Start: stdout = %q, want %q",
if got := stderr.String(); got != wantPayload {
t.Errorf("Start: stderr = %v, want %v",
got, wantPayload)
}
})
@ -111,27 +110,23 @@ func testHelper(t *testing.T, createHelper func(ctx context.Context, setOutput f
t.Run("start helper and wait", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
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)
h := createHelper(ctx, func(stdoutP, stderrP *io.Writer) { *stdoutP, *stderrP = stdout, stderr }, false)
if err := h.Start(); err != nil {
t.Errorf("Start: error = %v",
t.Errorf("Start() error = %v",
err)
return
}
if err := h.Wait(); err != nil {
t.Errorf("Wait: error = %v stdout = %q",
err, stdout)
t.Errorf("Wait() err = %v stdout = %s stderr = %s",
err, stdout, stderr)
}
if got := trimStdout(stdout); got != wantPayload {
t.Errorf("Start: stdout = %q, want %q",
if got := stderr.String(); got != wantPayload {
t.Errorf("Start() stderr = %v, want %v",
got, wantPayload)
}
})
}
func trimStdout(stdout fmt.Stringer) string {
return strings.TrimPrefix(stdout.String(), "=== RUN TestHelperInit\n")
}

View File

@ -63,7 +63,7 @@ func flagRestoreFiles(offset int, ap, sp string) (argsFile, statFile *os.File) {
func genericStub(argsFile, statFile *os.File) {
if argsFile != nil {
// this output is checked by parent
if _, err := io.Copy(os.Stdout, argsFile); err != nil {
if _, err := io.Copy(os.Stderr, argsFile); err != nil {
panic("cannot read args: " + err.Error())
}
}

View File

@ -56,15 +56,15 @@ var testCasesNixos = []sealTestCase{
},
system.New(1000001).
Ensure("/tmp/fortify.1971", 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
UpdatePermType(system.EWayland, "/run/user/1971/wayland-0", acl.Read, acl.Write, acl.Execute).
Ephemeral(system.Process, "/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1", 0711).
Ephemeral(system.Process, "/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/8e2c76b066dabe574cf073bdb46eb5c1", acl.Execute).
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).
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(nil, "/home/ophestra/xdg/config/pulse/cookie", 256, 256).
Ephemeral(system.Process, "/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1", 0711).
MustProxyDBus("/tmp/fortify.1971/8e2c76b066dabe574cf073bdb46eb5c1/bus", &dbus.Config{
Talk: []string{
"org.freedesktop.FileManager1", "org.freedesktop.Notifications",
@ -101,7 +101,6 @@ var testCasesNixos = []sealTestCase{
"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=wayland-0",
@ -204,7 +203,7 @@ var testCasesNixos = []sealTestCase{
Link(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Link(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Tmpfs("/run/user", 4096, 0755).
Tmpfs("/run/user/1971", 8388608, 0700).
Tmpfs("/run/user/1971", 8388608, 0755).
Bind("/tmp/fortify.1971/tmpdir/1", "/tmp", sandbox.BindWritable).
Bind("/var/lib/persist/module/fortify/0/1", "/var/lib/persist/module/fortify/0/1", sandbox.BindWritable).
Place("/etc/passwd", []byte("u0_a1:x:1971:100:Fortify:/var/lib/persist/module/fortify/0/1:/run/current-system/sw/bin/zsh\n")).

View File

@ -28,6 +28,10 @@ var testCasesPd = []sealTestCase{
},
system.New(1000000).
Ensure("/tmp/fortify.1971", 0711).
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, "/tmp/fortify.1971/4a450b6596d7bc15bd01780eb9a607ac", 0711).
Ephemeral(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/4a450b6596d7bc15bd01780eb9a607ac", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir", 0700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir", acl.Execute).
Ensure("/tmp/fortify.1971/tmpdir/0", 01700).UpdatePermType(system.User, "/tmp/fortify.1971/tmpdir/0", acl.Read, acl.Write, acl.Execute),
&sandbox.Params{
@ -37,7 +41,6 @@ var testCasesPd = []sealTestCase{
Args: []string{"/run/current-system/sw/bin/zsh"},
Env: []string{
"HOME=/home/chronos",
"SHELL=/run/current-system/sw/bin/zsh",
"TERM=xterm-256color",
"USER=chronos",
"XDG_RUNTIME_DIR=/run/user/65534",
@ -143,7 +146,7 @@ var testCasesPd = []sealTestCase{
Link(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Link(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Tmpfs("/run/user", 4096, 0755).
Tmpfs("/run/user/65534", 8388608, 0700).
Tmpfs("/run/user/65534", 8388608, 0755).
Bind("/tmp/fortify.1971/tmpdir/0", "/tmp", sandbox.BindWritable).
Bind("/home/chronos", "/home/chronos", sandbox.BindWritable).
Place("/etc/passwd", []byte("chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n")).
@ -203,13 +206,14 @@ var testCasesPd = []sealTestCase{
},
system.New(1000009).
Ensure("/tmp/fortify.1971", 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).
Ephemeral(system.Process, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c", 0711).
Wayland(new(*os.File), "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/1971/wayland-0", "org.chromium.Chromium", "ebf083d1b175911782d413369b64ce7c").
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, "/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c", 0711).
Ephemeral(system.Process, "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c", 0700).UpdatePermType(system.Process, "/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c", acl.Execute).
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("/tmp/fortify.1971/wayland", 0711).
Wayland(new(*os.File), "/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(new([]byte), "/home/ophestra/xdg/config/pulse/cookie", 256, 256).
MustProxyDBus("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", &dbus.Config{
@ -255,7 +259,6 @@ var testCasesPd = []sealTestCase{
"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=wayland-0",
@ -363,12 +366,12 @@ var testCasesPd = []sealTestCase{
Link(fst.Tmp+"/etc/zshenv", "/etc/zshenv").
Link(fst.Tmp+"/etc/zshrc", "/etc/zshrc").
Tmpfs("/run/user", 4096, 0755).
Tmpfs("/run/user/65534", 8388608, 0700).
Tmpfs("/run/user/65534", 8388608, 0755).
Bind("/tmp/fortify.1971/tmpdir/9", "/tmp", sandbox.BindWritable).
Bind("/home/chronos", "/home/chronos", sandbox.BindWritable).
Place("/etc/passwd", []byte("chronos:x:65534:65534:Fortify:/home/chronos:/run/current-system/sw/bin/zsh\n")).
Place("/etc/group", []byte("fortify:x:65534:\n")).
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/wayland", "/run/user/65534/wayland-0", 0).
Bind("/tmp/fortify.1971/wayland/ebf083d1b175911782d413369b64ce7c", "/run/user/65534/wayland-0", 0).
Bind("/run/user/1971/fortify/ebf083d1b175911782d413369b64ce7c/pulse", "/run/user/65534/pulse/native", 0).
Place(fst.Tmp+"/pulse-cookie", nil).
Bind("/tmp/fortify.1971/ebf083d1b175911782d413369b64ce7c/bus", "/run/user/65534/bus", 0).

View File

@ -8,6 +8,7 @@ import (
"fmt"
"io"
"io/fs"
"maps"
"os"
"path"
"regexp"
@ -85,53 +86,6 @@ type outcome struct {
f atomic.Bool
}
// shareHost holds optional share directory state that must not be accessed directly
type shareHost struct {
// whether XDG_RUNTIME_DIR is used post fsu
useRuntimeDir bool
// process-specific directory in tmpdir, empty if unused
sharePath string
// process-specific directory in XDG_RUNTIME_DIR, empty if unused
runtimeSharePath string
seal *outcome
sc fst.Paths
}
// ensureRuntimeDir must be called if direct access to paths within XDG_RUNTIME_DIR is required
func (share *shareHost) ensureRuntimeDir() {
if share.useRuntimeDir {
return
}
share.useRuntimeDir = true
share.seal.sys.Ensure(share.sc.RunDirPath, 0700)
share.seal.sys.UpdatePermType(system.User, share.sc.RunDirPath, acl.Execute)
share.seal.sys.Ensure(share.sc.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset
share.seal.sys.UpdatePermType(system.User, share.sc.RuntimePath, acl.Execute)
}
// instance returns a process-specific share path within tmpdir
func (share *shareHost) instance() string {
if share.sharePath != "" {
return share.sharePath
}
share.sharePath = path.Join(share.sc.SharePath, share.seal.id.String())
share.seal.sys.Ephemeral(system.Process, share.sharePath, 0711)
return share.sharePath
}
// runtime returns a process-specific share path within XDG_RUNTIME_DIR
func (share *shareHost) runtime() string {
if share.runtimeSharePath != "" {
return share.runtimeSharePath
}
share.ensureRuntimeDir()
share.runtimeSharePath = path.Join(share.sc.RunDirPath, share.seal.id.String())
share.seal.sys.Ephemeral(system.Process, share.runtimeSharePath, 0700)
share.seal.sys.UpdatePerm(share.runtimeSharePath, acl.Execute)
return share.runtimeSharePath
}
// fsuUser stores post-fsu credentials and metadata
type fsuUser struct {
// application id
@ -156,6 +110,11 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
}
seal.ctx = ctx
shellPath := "/bin/sh"
if s, ok := sys.LookupEnv(shell); ok && path.IsAbs(s) {
shellPath = s
}
{
// encode initial configuration for state tracking
ct := new(bytes.Buffer)
@ -172,6 +131,10 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
fmt.Sprintf("aid %d out of range", config.Confinement.AppID))
}
/*
Resolve post-fsu user state
*/
seal.user = fsuUser{
aid: newInt(config.Confinement.AppID),
data: config.Confinement.Outer,
@ -207,14 +170,9 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
}
}
// this also falls back to host path if encountering an invalid path
if !path.IsAbs(config.Confinement.Shell) {
config.Confinement.Shell = "/bin/sh"
if s, ok := sys.LookupEnv(shell); ok && path.IsAbs(s) {
config.Confinement.Shell = s
}
}
// do not use the value of shell before this point
/*
Resolve initial container state
*/
// permissive defaults
if config.Confinement.Sandbox == nil {
@ -229,7 +187,7 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
config.Path = p
}
} else {
config.Path = config.Confinement.Shell
config.Path = shellPath
}
}
@ -297,56 +255,85 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
mapuid = newInt(uid)
mapgid = newInt(gid)
if seal.env == nil {
seal.env = make(map[string]string, 1<<6)
seal.env = make(map[string]string)
}
}
// inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as mapped uid
/*
Initialise externals
*/
sc := sys.Paths()
seal.runDirPath = sc.RunDirPath
seal.sys = system.New(seal.user.uid.unwrap())
/*
Work directories
*/
// base fortify share path
seal.sys.Ensure(sc.SharePath, 0711)
// outer paths used by the main process
seal.sys.Ensure(sc.RunDirPath, 0700)
seal.sys.UpdatePermType(system.User, sc.RunDirPath, acl.Execute)
seal.sys.Ensure(sc.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset
seal.sys.UpdatePermType(system.User, sc.RuntimePath, acl.Execute)
// outer process-specific share directory
sharePath := path.Join(sc.SharePath, seal.id.String())
seal.sys.Ephemeral(system.Process, sharePath, 0711)
// similar to share but within XDG_RUNTIME_DIR
sharePathLocal := path.Join(sc.RunDirPath, seal.id.String())
seal.sys.Ephemeral(system.Process, sharePathLocal, 0700)
seal.sys.UpdatePerm(sharePathLocal, acl.Execute)
// inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as post-fsu user
innerRuntimeDir := path.Join("/run/user", mapuid.String())
seal.container.Tmpfs("/run/user", 1<<12, 0755)
seal.container.Tmpfs(innerRuntimeDir, 1<<23, 0700)
seal.container.Tmpfs(innerRuntimeDir, 1<<23, 0755)
seal.env[xdgRuntimeDir] = innerRuntimeDir
seal.env[xdgSessionClass] = "user"
seal.env[xdgSessionType] = "tty"
share := &shareHost{seal: seal, sc: sys.Paths()}
seal.runDirPath = share.sc.RunDirPath
seal.sys = system.New(seal.user.uid.unwrap())
// outer path for inner /tmp
{
seal.sys.Ensure(share.sc.SharePath, 0711)
tmpdir := path.Join(share.sc.SharePath, "tmpdir")
tmpdir := path.Join(sc.SharePath, "tmpdir")
seal.sys.Ensure(tmpdir, 0700)
seal.sys.UpdatePermType(system.User, tmpdir, acl.Execute)
tmpdirInst := path.Join(tmpdir, seal.user.aid.String())
seal.sys.Ensure(tmpdirInst, 01700)
seal.sys.UpdatePermType(system.User, tmpdirInst, acl.Read, acl.Write, acl.Execute)
// mount inner /tmp from share so it shares persistence and storage behaviour of host /tmp
seal.container.Bind(tmpdirInst, "/tmp", sandbox.BindWritable)
}
{
homeDir := "/var/empty"
if seal.user.home != "" {
homeDir = seal.user.home
}
username := "chronos"
if seal.user.username != "" {
username = seal.user.username
}
seal.container.Bind(seal.user.data, homeDir, sandbox.BindWritable)
seal.container.Dir = homeDir
seal.env["HOME"] = homeDir
seal.env["USER"] = username
seal.env[shell] = config.Confinement.Shell
/*
Passwd database
*/
seal.container.Place("/etc/passwd",
[]byte(username+":x:"+mapuid.String()+":"+mapgid.String()+":Fortify:"+homeDir+":"+config.Confinement.Shell+"\n"))
seal.container.Place("/etc/group",
[]byte("fortify:x:"+mapgid.String()+":\n"))
homeDir := "/var/empty"
if seal.user.home != "" {
homeDir = seal.user.home
}
username := "chronos"
if seal.user.username != "" {
username = seal.user.username
}
seal.container.Bind(seal.user.data, homeDir, sandbox.BindWritable)
seal.container.Dir = homeDir
seal.env["HOME"] = homeDir
seal.env["USER"] = username
// pass TERM for proper terminal I/O in initial process
seal.container.Place("/etc/passwd",
[]byte(username+":x:"+mapuid.String()+":"+mapgid.String()+":Fortify:"+homeDir+":"+shellPath+"\n"))
seal.container.Place("/etc/group",
[]byte("fortify:x:"+mapgid.String()+":\n"))
/*
Display servers
*/
// pass $TERM for proper terminal I/O in shell
if t, ok := sys.LookupEnv(term); ok {
seal.env[term] = t
}
@ -356,9 +343,9 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
var socketPath string
if name, ok := sys.LookupEnv(wl.WaylandDisplay); !ok {
fmsg.Verbose(wl.WaylandDisplay + " is not set, assuming " + wl.FallbackName)
socketPath = path.Join(share.sc.RuntimePath, wl.FallbackName)
socketPath = path.Join(sc.RuntimePath, wl.FallbackName)
} else if !path.IsAbs(name) {
socketPath = path.Join(share.sc.RuntimePath, name)
socketPath = path.Join(sc.RuntimePath, name)
} else {
socketPath = name
}
@ -367,18 +354,18 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
seal.env[wl.WaylandDisplay] = wl.FallbackName
if !config.Confinement.Sandbox.DirectWayland { // set up security-context-v1
socketDir := path.Join(sc.SharePath, "wayland")
outerPath := path.Join(socketDir, seal.id.String())
seal.sys.Ensure(socketDir, 0711)
appID := config.ID
if appID == "" {
// use instance ID in case app id is not set
appID = "uk.gensokyo.fortify." + seal.id.String()
}
// downstream socket paths
outerPath := path.Join(share.instance(), "wayland")
seal.sys.Wayland(&seal.sync, outerPath, socketPath, appID, seal.id.String())
seal.container.Bind(outerPath, innerPath, 0)
} else { // bind mount wayland socket (insecure)
fmsg.Verbose("direct wayland access, PROCEED WITH CAUTION")
share.ensureRuntimeDir()
seal.container.Bind(socketPath, innerPath, 0)
seal.sys.UpdatePermType(system.EWayland, socketPath, acl.Read, acl.Write, acl.Execute)
}
@ -395,9 +382,13 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
}
}
/*
PulseAudio server and authentication
*/
if config.Confinement.Enablements&system.EPulse != 0 {
// PulseAudio runtime directory (usually `/run/user/%d/pulse`)
pulseRuntimeDir := path.Join(share.sc.RuntimePath, "pulse")
pulseRuntimeDir := path.Join(sc.RuntimePath, "pulse")
// PulseAudio socket (usually `/run/user/%d/pulse/native`)
pulseSocket := path.Join(pulseRuntimeDir, "native")
@ -425,7 +416,7 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
}
// hard link pulse socket into target-executable share
innerPulseRuntimeDir := path.Join(share.runtime(), "pulse")
innerPulseRuntimeDir := path.Join(sharePathLocal, "pulse")
innerPulseSocket := path.Join(innerRuntimeDir, "pulse", "native")
seal.sys.Link(pulseSocket, innerPulseRuntimeDir)
seal.container.Bind(innerPulseRuntimeDir, innerPulseSocket, 0)
@ -444,6 +435,10 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
}
}
/*
D-Bus proxy
*/
if config.Confinement.Enablements&system.EDBus != 0 {
// ensure dbus session bus defaults
if config.Confinement.SessionBus == nil {
@ -451,7 +446,6 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
}
// downstream socket paths
sharePath := share.instance()
sessionPath, systemPath := path.Join(sharePath, "bus"), path.Join(sharePath, "system_bus_socket")
// configure dbus proxy
@ -477,6 +471,10 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
}
}
/*
Miscellaneous
*/
for _, dest := range config.Confinement.Sandbox.Cover {
seal.container.Tmpfs(dest, 1<<13, 0755)
}
@ -506,13 +504,7 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
// flatten and sort env for deterministic behaviour
seal.container.Env = make([]string, 0, len(seal.env))
for k, v := range seal.env {
if strings.IndexByte(k, '=') != -1 {
return fmsg.WrapError(syscall.EINVAL,
fmt.Sprintf("invalid environment variable %s", k))
}
seal.container.Env = append(seal.container.Env, k+"="+v)
}
maps.All(seal.env)(func(k string, v string) bool { seal.container.Env = append(seal.container.Env, k+"="+v); return true })
slices.Sort(seal.container.Env)
fmsg.Verbosef("created application seal for uid %s (%s) groups: %v, argv: %s",

View File

@ -33,10 +33,10 @@ func (s *multiStore) Do(aid int, f func(c Cursor)) (bool, error) {
// load or initialise new backend
b := new(multiBackend)
b.lock.Lock()
if v, ok := s.backends.LoadOrStore(aid, b); ok {
b = v.(*multiBackend)
} else {
b.lock.Lock()
b.path = path.Join(s.base, strconv.Itoa(aid))
// ensure directory

View File

@ -47,7 +47,7 @@ type State interface {
Uid(aid int) (int, error)
}
// CopyPaths is a generic implementation of [fst.Paths].
// CopyPaths is a generic implementation of [System.Paths].
func CopyPaths(os State, v *fst.Paths) {
v.SharePath = path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Getuid()))

View File

@ -88,15 +88,11 @@ in
conf = {
inherit (app) id;
path =
if app.path == null then
pkgs.writeScript "${app.name}-start" ''
#!${pkgs.zsh}${pkgs.zsh.shellPath}
${script}
''
else
app.path;
args = if app.args == null then [ "${app.name}-start" ] else app.args;
path = pkgs.writeScript "${app.name}-start" ''
#!${pkgs.zsh}${pkgs.zsh.shellPath}
${script}
'';
args = [ "${app.name}-start" ];
confinement = {
app_id = aid;
@ -201,11 +197,9 @@ in
${copy "${pkg}/share/icons"}
${copy "${pkg}/share/man"}
if test -d "$out/share/applications"; then
substituteInPlace $out/share/applications/* \
--replace-warn '${pkg}/bin/' "" \
--replace-warn '${pkg}/libexec/' ""
fi
substituteInPlace $out/share/applications/* \
--replace-warn '${pkg}/bin/' "" \
--replace-warn '${pkg}/libexec/' ""
''
)
++ acc

View File

@ -35,7 +35,7 @@ package
*Default:*
` <derivation fortify-static-x86_64-unknown-linux-musl-0.3.3> `
` <derivation fortify-static-x86_64-unknown-linux-musl-0.3.0> `
@ -73,25 +73,6 @@ list of package
## environment\.fortify\.apps\.\*\.args
Custom args\.
Setting this to null will default to script name\.
*Type:*
null or (list of string)
*Default:*
` null `
## environment\.fortify\.apps\.\*\.capability\.dbus
@ -505,25 +486,6 @@ boolean
## environment\.fortify\.apps\.\*\.path
Custom executable path\.
Setting this to null will default to the start script\.
*Type:*
null or string
*Default:*
` null `
## environment\.fortify\.apps\.\*\.script
@ -644,7 +606,7 @@ package
*Default:*
` <derivation fortify-fsu-0.3.3> `
` <derivation fortify-fsu-0.3.0> `

View File

@ -94,24 +94,6 @@ in
'';
};
path = mkOption {
type = nullOr str;
default = null;
description = ''
Custom executable path.
Setting this to null will default to the start script.
'';
};
args = mkOption {
type = nullOr (listOf str);
default = null;
description = ''
Custom args.
Setting this to null will default to script name.
'';
};
script = mkOption {
type = nullOr str;
default = null;

View File

@ -31,7 +31,7 @@
buildGoModule rec {
pname = "fortify";
version = "0.3.3";
version = "0.3.0";
src = builtins.path {
name = "${pname}-src";

View File

@ -77,9 +77,7 @@ func printShowInstance(
if len(config.Confinement.Groups) > 0 {
t.Printf(" Groups:\t%q\n", config.Confinement.Groups)
}
if config.Confinement.Outer != "" {
t.Printf(" Directory:\t%s\n", config.Confinement.Outer)
}
t.Printf(" Directory:\t%s\n", config.Confinement.Outer)
if config.Confinement.Sandbox != nil {
sandbox := config.Confinement.Sandbox
if sandbox.Hostname != "" {
@ -116,12 +114,7 @@ func printShowInstance(
// Env map[string]string `json:"env"`
// Link [][2]string `json:"symlink"`
}
if config.Confinement.Sandbox != nil {
t.Printf(" Path:\t%s\n", config.Path)
}
if len(config.Args) > 0 {
t.Printf(" Arguments:\t%s\n", strings.Join(config.Args, " "))
}
t.Printf(" Command:\t%s\n", strings.Join(config.Args, " "))
t.Printf("\n")
if !short {
@ -254,18 +247,22 @@ func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON boo
t := newPrinter(output)
defer t.MustFlush()
t.Println("\tInstance\tPID\tApplication\tUptime")
t.Println("\tInstance\tPID\tApp\tUptime\tEnablements\tCommand")
for _, e := range exp {
as := "(No configuration information)"
var (
es = "(No confinement information)"
cs = "(No command information)"
as = "(No configuration information)"
)
if e.Config != nil {
es = e.Config.Confinement.Enablements.String()
cs = fmt.Sprintf("%q", e.Config.Args)
as = strconv.Itoa(e.Config.Confinement.AppID)
if e.Config.ID != "" {
as += " (" + e.Config.ID + ")"
}
}
t.Printf("\t%s\t%d\t%s\t%s\n",
e.s[:8], e.PID, as, now.Sub(e.Time).Round(time.Second).String())
t.Printf("\t%s\t%d\t%s\t%s\t%s\t%s\n",
e.s[:8], e.PID, as, now.Sub(e.Time).Round(time.Second).String(), strings.TrimPrefix(es, ", "), cs)
}
t.Println()
}
type expandedStateEntry struct {

View File

@ -44,8 +44,7 @@ func Test_printShowInstance(t *testing.T) {
Flags: userns net dev tty mapuid autoetc
Etc: /etc
Cover: /var/run/nscd
Path: /run/current-system/sw/bin/chromium
Arguments: chromium --ignore-gpu-blocklist --disable-smooth-scrolling --enable-features=UseOzonePlatform --ozone-platform=wayland
Command: chromium --ignore-gpu-blocklist --disable-smooth-scrolling --enable-features=UseOzonePlatform --ozone-platform=wayland
Filesystem
+/nix/store
@ -76,22 +75,26 @@ System bus
App
ID: 0
Enablements: (no enablements)
Directory:
Command:
`},
{"config flag none", nil, &fst.Config{Confinement: fst.ConfinementConfig{Sandbox: new(fst.SandboxConfig)}}, false, false, `App
ID: 0
Enablements: (no enablements)
Directory:
Flags: none
Etc: /etc
Path:
Command:
`},
{"config nil entries", nil, &fst.Config{Confinement: fst.ConfinementConfig{Sandbox: &fst.SandboxConfig{Filesystem: make([]*fst.FilesystemConfig, 1)}, ExtraPerms: make([]*fst.ExtraPermConfig, 1)}}, false, false, `App
ID: 0
Enablements: (no enablements)
Directory:
Flags: none
Etc: /etc
Path:
Command:
Filesystem
@ -103,6 +106,8 @@ Extra ACL
App
ID: 0
Enablements: (no enablements)
Directory:
Command:
Session bus
Filter: false
@ -123,8 +128,7 @@ App
Flags: userns net dev tty mapuid autoetc
Etc: /etc
Cover: /var/run/nscd
Path: /run/current-system/sw/bin/chromium
Arguments: chromium --ignore-gpu-blocklist --disable-smooth-scrolling --enable-features=UseOzonePlatform --ozone-platform=wayland
Command: chromium --ignore-gpu-blocklist --disable-smooth-scrolling --enable-features=UseOzonePlatform --ozone-platform=wayland
Filesystem
+/nix/store
@ -159,6 +163,8 @@ State
App
ID: 0
Enablements: (no enablements)
Directory:
Command:
`},
@ -202,7 +208,6 @@ App
"username": "chronos",
"home_inner": "/var/lib/fortify",
"home": "/var/lib/persist/home/org.chromium.Chromium",
"shell": "/run/current-system/sw/bin/zsh",
"sandbox": {
"hostname": "localhost",
"seccomp": 32,
@ -327,7 +332,6 @@ App
"username": "chronos",
"home_inner": "/var/lib/fortify",
"home": "/var/lib/persist/home/org.chromium.Chromium",
"shell": "/run/current-system/sw/bin/zsh",
"sandbox": {
"hostname": "localhost",
"seccomp": 32,
@ -454,16 +458,20 @@ func Test_printPs(t *testing.T) {
short, json bool
want string
}{
{"no entries", make(state.Entries), false, false, ` Instance PID Application Uptime
{"no entries", make(state.Entries), false, false, ` Instance PID App Uptime Enablements Command
`},
{"no entries short", make(state.Entries), true, false, ``},
{"nil instance", state.Entries{testID: nil}, false, false, ` Instance PID Application Uptime
{"nil instance", state.Entries{testID: nil}, false, false, ` Instance PID App Uptime Enablements Command
`},
{"state corruption", state.Entries{fst.ID{}: testState}, false, false, ` Instance PID Application Uptime
{"state corruption", state.Entries{fst.ID{}: testState}, false, false, ` Instance PID App Uptime Enablements Command
`},
{"valid", state.Entries{testID: testState}, false, false, ` Instance PID Application Uptime
8e2c76b0 3735928559 9 (org.chromium.Chromium) 1h2m32s
{"valid", state.Entries{testID: testState}, false, false, ` Instance PID App Uptime Enablements Command
8e2c76b0 3735928559 9 1h2m32s wayland, dbus, pulseaudio ["chromium" "--ignore-gpu-blocklist" "--disable-smooth-scrolling" "--enable-features=UseOzonePlatform" "--ozone-platform=wayland"]
`},
{"valid short", state.Entries{testID: testState}, true, false, `8e2c76b0
`},
@ -506,7 +514,6 @@ func Test_printPs(t *testing.T) {
"username": "chronos",
"home_inner": "/var/lib/fortify",
"home": "/var/lib/persist/home/org.chromium.Chromium",
"shell": "/run/current-system/sw/bin/zsh",
"sandbox": {
"hostname": "localhost",
"seccomp": 32,

View File

@ -99,11 +99,19 @@ type (
// Permission bits of newly created parent directories.
// The zero value is interpreted as 0755.
ParentPerm os.FileMode
// Retain CAP_SYS_ADMIN.
Privileged bool
Flags HardeningFlags
}
Ops []Op
Op interface {
early(params *Params) error
apply(params *Params) error
prefix() string
Is(op Op) bool
fmt.Stringer
}
)
func (p *Container) Start() error {
@ -157,7 +165,7 @@ func (p *Container) Start() error {
syscall.CLONE_NEWNS,
// remain privileged for setup
AmbientCaps: []uintptr{CAP_SYS_ADMIN, CAP_SETPCAP},
AmbientCaps: []uintptr{CAP_SYS_ADMIN},
UseCgroupFD: p.Cgroup != nil,
}

View File

@ -45,6 +45,10 @@ func Init(prepare func(prefix string), setVerbose func(verbose bool)) {
log.Fatal("this process must run as pid 1")
}
/*
receive setup payload
*/
var (
params initParams
closeSetup func() error
@ -104,8 +108,9 @@ func Init(prepare func(prefix string), setVerbose func(verbose bool)) {
}
}
// cache sysctl before pivot_root
LastCap()
/*
set up mount points from intermediate root
*/
if err := syscall.Mount("", "/", "",
syscall.MS_SILENT|syscall.MS_SLAVE|syscall.MS_REC,
@ -147,7 +152,6 @@ func Init(prepare func(prefix string), setVerbose func(verbose bool)) {
if err := os.Mkdir(hostDir, 0755); err != nil {
log.Fatalf("%v", err)
}
// pivot_root uncovers basePath in hostDir
if err := syscall.PivotRoot(basePath, hostDir); err != nil {
log.Fatalf("cannot pivot into intermediate root: %v", err)
}
@ -166,7 +170,10 @@ func Init(prepare func(prefix string), setVerbose func(verbose bool)) {
}
}
// setup requiring host root complete at this point
/*
pivot to sysroot
*/
if err := syscall.Mount(hostDir, hostDir, "",
syscall.MS_SILENT|syscall.MS_REC|syscall.MS_PRIVATE,
""); err != nil {
@ -206,48 +213,33 @@ func Init(prepare func(prefix string), setVerbose func(verbose bool)) {
}
}
if _, _, errno := syscall.Syscall(PR_SET_NO_NEW_PRIVS, 1, 0, 0); errno != 0 {
log.Fatalf("prctl(PR_SET_NO_NEW_PRIVS): %v", errno)
}
/*
load seccomp filter
*/
if _, _, errno := syscall.Syscall(syscall.SYS_PRCTL, PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0); errno != 0 {
log.Fatalf("cannot clear the ambient capability set: %v", errno)
if _, _, err := syscall.Syscall(PR_SET_NO_NEW_PRIVS, 1, 0, 0); err != 0 {
log.Fatalf("prctl(PR_SET_NO_NEW_PRIVS): %v", err)
}
for i := uintptr(0); i <= LastCap(); i++ {
if params.Privileged && i == CAP_SYS_ADMIN {
continue
}
if _, _, errno := syscall.Syscall(syscall.SYS_PRCTL, syscall.PR_CAPBSET_DROP, i, 0); errno != 0 {
log.Fatalf("cannot drop capability from bonding set: %v", errno)
}
}
var keep [2]uint32
if params.Privileged {
keep[capToIndex(CAP_SYS_ADMIN)] |= capToMask(CAP_SYS_ADMIN)
if _, _, errno := syscall.Syscall(syscall.SYS_PRCTL, PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, CAP_SYS_ADMIN); errno != 0 {
log.Fatalf("cannot raise CAP_SYS_ADMIN: %v", errno)
}
}
if err := capset(
&capHeader{_LINUX_CAPABILITY_VERSION_3, 0},
&[2]capData{{0, keep[0], keep[0]}, {0, keep[1], keep[1]}},
); err != nil {
log.Fatalf("cannot capset: %v", err)
}
if err := seccomp.Load(params.Flags.seccomp(params.Seccomp)); err != nil {
log.Fatalf("cannot load syscall filter: %v", err)
}
/* at this point CAP_SYS_ADMIN can be dropped, however it is kept for now as it does not increase attack surface */
/*
pass through extra files
*/
extraFiles := make([]*os.File, params.Count)
for i := range extraFiles {
// setup fd is placed before all extra files
extraFiles[i] = os.NewFile(uintptr(offsetSetup+i), "extra file "+strconv.Itoa(i))
}
syscall.Umask(oldmask)
/*
prepare initial process
*/
cmd := exec.Command(params.Path)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Args = params.Args
@ -260,11 +252,22 @@ func Init(prepare func(prefix string), setVerbose func(verbose bool)) {
}
msg.Suspend()
/*
close setup pipe
*/
if err := closeSetup(); err != nil {
log.Println("cannot close setup pipe:", err)
// not fatal
}
/*
perform init duties
*/
sig := make(chan os.Signal, 2)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
type winfo struct {
wpid int
wstatus syscall.WaitStatus
@ -301,10 +304,6 @@ func Init(prepare func(prefix string), setVerbose func(verbose bool)) {
close(done)
}()
// handle signals to dump withheld messages
sig := make(chan os.Signal, 2)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
// closed after residualProcessTimeout has elapsed after initial process death
timeout := make(chan struct{})
@ -317,6 +316,7 @@ func Init(prepare func(prefix string), setVerbose func(verbose bool)) {
} else {
msg.Verbosef("terminating on %s", s.String())
}
msg.BeforeExit()
os.Exit(0)
case w := <-info:
if w.wpid == cmd.Process.Pid {

37
sandbox/overflow.go Normal file
View File

@ -0,0 +1,37 @@
package sandbox
import (
"bytes"
"log"
"os"
"strconv"
"sync"
)
var (
ofUid int
ofGid int
ofOnce sync.Once
)
const (
ofUidPath = "/proc/sys/kernel/overflowuid"
ofGidPath = "/proc/sys/kernel/overflowgid"
)
func mustReadOverflow() {
if v, err := os.ReadFile(ofUidPath); err != nil {
log.Fatalf("cannot read %q: %v", ofUidPath, err)
} else if ofUid, err = strconv.Atoi(string(bytes.TrimSpace(v))); err != nil {
log.Fatalf("cannot interpret %q: %v", ofUidPath, err)
}
if v, err := os.ReadFile(ofGidPath); err != nil {
log.Fatalf("cannot read %q: %v", ofGidPath, err)
} else if ofGid, err = strconv.Atoi(string(bytes.TrimSpace(v))); err != nil {
log.Fatalf("cannot interpret %q: %v", ofGidPath, err)
}
}
func OverflowUid() int { ofOnce.Do(mustReadOverflow); return ofUid }
func OverflowGid() int { ofOnce.Do(mustReadOverflow); return ofGid }

View File

@ -73,16 +73,6 @@ func TestExport(t *testing.T) {
0x80, 0x8b, 0x1a, 0x6f, 0x84, 0xf3, 0x2b, 0xbd,
0xe1, 0xaa, 0x02, 0xae, 0x30, 0xee, 0xdc, 0xfa,
}, false},
{"fortify default", seccomp.FlagExt | seccomp.FlagDenyDevel, []byte{
0xc6, 0x98, 0xb0, 0x81, 0xff, 0x95, 0x7a, 0xfe,
0x17, 0xa6, 0xd9, 0x43, 0x74, 0x53, 0x7d, 0x37,
0xf2, 0xa6, 0x3f, 0x6f, 0x9d, 0xd7, 0x5d, 0xa7,
0x54, 0x65, 0x42, 0x40, 0x7a, 0x9e, 0x32, 0x47,
0x6e, 0xbd, 0xa3, 0x31, 0x2b, 0xa7, 0x78, 0x5d,
0x7f, 0x61, 0x85, 0x42, 0xbc, 0xfa, 0xf2, 0x7c,
0xa2, 0x7d, 0xcc, 0x2d, 0xdd, 0xba, 0x85, 0x20,
0x69, 0xd2, 0x8b, 0xcf, 0xe8, 0xca, 0xd3, 0x9a,
}, false},
}
buf := make([]byte, 8)

View File

@ -1,4 +1,3 @@
// Package seccomp provides filter presets and high level wrappers around libseccomp.
package seccomp
/*

View File

@ -13,22 +13,6 @@ import (
"unsafe"
)
type (
Ops []Op
Op interface {
// early is called in host root.
early(params *Params) error
// apply is called in intermediate root.
apply(params *Params) error
prefix() string
Is(op Op) bool
fmt.Stringer
}
)
func (f *Ops) Grow(n int) { *f = slices.Grow(*f, n) }
func init() { gob.Register(new(BindMount)) }
// BindMount bind mounts host path Source on container path Target.

View File

@ -1,17 +1,11 @@
package sandbox
import (
"syscall"
"unsafe"
)
import "syscall"
const (
O_PATH = 0x200000
O_PATH = 0x200000
PR_SET_NO_NEW_PRIVS = 0x26
CAP_SYS_ADMIN = 0x15
CAP_SETPCAP = 0x8
CAP_SYS_ADMIN = 0x15
)
const (
@ -21,49 +15,13 @@ const (
func SetDumpable(dumpable uintptr) error {
// linux/sched/coredump.h
if _, _, errno := syscall.Syscall(syscall.SYS_PRCTL, syscall.PR_SET_DUMPABLE, dumpable, 0); errno != 0 {
if _, _, errno := syscall.RawSyscall(syscall.SYS_PRCTL, syscall.PR_SET_DUMPABLE, dumpable, 0); errno != 0 {
return errno
}
return nil
}
const (
_LINUX_CAPABILITY_VERSION_3 = 0x20080522
PR_CAP_AMBIENT = 0x2f
PR_CAP_AMBIENT_RAISE = 0x2
PR_CAP_AMBIENT_CLEAR_ALL = 0x4
)
type (
capHeader struct {
version uint32
pid int32
}
capData struct {
effective uint32
permitted uint32
inheritable uint32
}
)
// See CAP_TO_INDEX in linux/capability.h:
func capToIndex(cap uintptr) uintptr { return cap >> 5 }
// See CAP_TO_MASK in linux/capability.h:
func capToMask(cap uintptr) uint32 { return 1 << uint(cap&31) }
func capset(hdrp *capHeader, datap *[2]capData) error {
if _, _, errno := syscall.Syscall(syscall.SYS_CAPSET,
uintptr(unsafe.Pointer(hdrp)),
uintptr(unsafe.Pointer(&datap[0])), 0); errno != 0 {
return errno
}
return nil
}
// IgnoringEINTR makes a function call and repeats it if it returns an
// EINTR error. This appears to be required even though we install all
// signal handlers with SA_RESTART: see #22838, #38033, #38836, #40846.

View File

@ -1,47 +0,0 @@
package sandbox
import (
"bytes"
"log"
"os"
"strconv"
"sync"
)
var (
kernelOverflowuid int
kernelOverflowgid int
kernelCapLastCap int
sysctlOnce sync.Once
)
const (
kernelOverflowuidPath = "/proc/sys/kernel/overflowuid"
kernelOverflowgidPath = "/proc/sys/kernel/overflowgid"
kernelCapLastCapPath = "/proc/sys/kernel/cap_last_cap"
)
func mustReadSysctl() {
if v, err := os.ReadFile(kernelOverflowuidPath); err != nil {
log.Fatalf("cannot read %q: %v", kernelOverflowuidPath, err)
} else if kernelOverflowuid, err = strconv.Atoi(string(bytes.TrimSpace(v))); err != nil {
log.Fatalf("cannot interpret %q: %v", kernelOverflowuidPath, err)
}
if v, err := os.ReadFile(kernelOverflowgidPath); err != nil {
log.Fatalf("cannot read %q: %v", kernelOverflowgidPath, err)
} else if kernelOverflowgid, err = strconv.Atoi(string(bytes.TrimSpace(v))); err != nil {
log.Fatalf("cannot interpret %q: %v", kernelOverflowgidPath, err)
}
if v, err := os.ReadFile(kernelCapLastCapPath); err != nil {
log.Fatalf("cannot read %q: %v", kernelCapLastCapPath, err)
} else if kernelCapLastCap, err = strconv.Atoi(string(bytes.TrimSpace(v))); err != nil {
log.Fatalf("cannot interpret %q: %v", kernelCapLastCapPath, err)
}
}
func OverflowUid() int { sysctlOnce.Do(mustReadSysctl); return kernelOverflowuid }
func OverflowGid() int { sysctlOnce.Do(mustReadSysctl); return kernelOverflowgid }
func LastCap() uintptr { sysctlOnce.Do(mustReadSysctl); return uintptr(kernelCapLastCap) }

View File

@ -4,6 +4,12 @@
config,
...
}:
let
testCases = import ./sandbox/case {
inherit (pkgs) lib callPackage foot;
inherit (config.environment.fortify.package) version;
};
in
{
users.users = {
alice = {
@ -102,6 +108,10 @@
home-manager = _: _: { home.stateVersion = "23.05"; };
apps = [
testCases.preset
testCases.tty
testCases.mapuid
{
name = "ne-foot";
verbose = true;

View File

@ -7,15 +7,10 @@ in the public sandbox/vfs package. Files in this package are excluded by the bui
package sandbox
import (
"crypto/sha512"
"encoding/hex"
"encoding/json"
"errors"
"io/fs"
"log"
"os"
"syscall"
"time"
)
var (
@ -28,7 +23,6 @@ func printf(format string, v ...any) { printfFunc(format, v...) }
func fatalf(format string, v ...any) { fatalfFunc(format, v...) }
type TestCase struct {
Env []string `json:"env"`
FS *FS `json:"fs"`
Mount []*MountinfoEntry `json:"mount"`
Seccomp bool `json:"seccomp"`
@ -40,46 +34,13 @@ type T struct {
MountsPath string
}
func (t *T) MustCheckFile(wantFilePath, markerPath string) {
func (t *T) MustCheckFile(wantFilePath string) {
var want *TestCase
mustDecode(wantFilePath, &want)
t.MustCheck(want)
if _, err := os.Create(markerPath); err != nil {
fatalf("cannot create success marker: %v", err)
}
}
func (t *T) MustCheck(want *TestCase) {
if want.Env != nil {
var (
fail bool
i int
got string
)
for i, got = range os.Environ() {
if i == len(want.Env) {
fatalf("got more than %d environment variables", len(want.Env))
}
if got != want.Env[i] {
fail = true
printf("[FAIL] %s", got)
} else {
printf("[ OK ] %s", got)
}
}
i++
if i != len(want.Env) {
fatalf("got %d environment variables, want %d", i, len(want.Env))
}
if fail {
fatalf("[FAIL] some environment variables did not match")
}
} else {
printf("[SKIP] skipping environ check")
}
if want.FS != nil && t.FS != nil {
if err := want.FS.Compare(".", t.FS); err != nil {
fatalf("%v", err)
@ -121,7 +82,7 @@ func (t *T) MustCheck(want *TestCase) {
}
if want.Seccomp {
if trySyscalls() != nil {
if TrySyscalls() != nil {
os.Exit(1)
}
} else {
@ -129,81 +90,6 @@ func (t *T) MustCheck(want *TestCase) {
}
}
func MustCheckFilter(pid int, want string) {
err := CheckFilter(pid, want)
if err == nil {
return
}
var perr *ptraceError
if !errors.As(err, &perr) {
fatalf("%s", err)
}
switch perr.op {
case "PTRACE_ATTACH":
fatalf("cannot attach to process %d: %v", pid, err)
case "PTRACE_SECCOMP_GET_FILTER":
if perr.errno == syscall.ENOENT {
fatalf("seccomp filter not installed for process %d", pid)
}
fatalf("cannot get filter: %v", err)
default:
fatalf("cannot check filter: %v", err)
}
*(*int)(nil) = 0 // not reached
}
func CheckFilter(pid int, want string) error {
if err := ptraceAttach(pid); err != nil {
return err
}
defer func() {
if err := ptraceDetach(pid); err != nil {
printf("cannot detach from process %d: %v", pid, err)
}
}()
h := sha512.New()
{
getFilter:
buf, err := getFilter[[8]byte](pid, 0)
/* this is not how ESRCH should be handled: the manpage advises the
use of waitpid, however that is not applicable for attaching to an
arbitrary process, and spawning target process here is not easily
possible under the current testing framework;
despite checking for /proc/pid/status indicating state t (tracing stop),
it does not appear to be directly related to the internal state used to
determine whether a process is ready to accept ptrace operations, it also
introduces a TOCTOU that is irrelevant in the testing vm; this behaviour
is kept anyway as it reduces the average iterations required here;
since this code is only ever compiled into the test program, whatever
implications this ugliness might have should not hurt anyone */
if errors.Is(err, syscall.ESRCH) {
time.Sleep(100 * time.Millisecond)
goto getFilter
}
if err != nil {
return err
}
for _, b := range buf {
h.Write(b[:])
}
}
if got := hex.EncodeToString(h.Sum(nil)); got != want {
printf("[FAIL] %s", got)
return syscall.ENOTRECOVERABLE
} else {
printf("[ OK ] %s", got)
return nil
}
}
func mustDecode(wantFilePath string, v any) {
if f, err := os.Open(wantFilePath); err != nil {
fatalf("cannot open %q: %v", wantFilePath, err)

30
test/sandbox/assert.nix Normal file
View File

@ -0,0 +1,30 @@
{
writeText,
buildGoModule,
pkg-config,
util-linux,
version,
}:
buildGoModule {
pname = "check-sandbox";
inherit version;
src = ../.;
vendorHash = null;
buildInputs = [ util-linux ];
nativeBuildInputs = [ pkg-config ];
preBuild = ''
go mod init git.gensokyo.uk/security/fortify/test >& /dev/null
cp ${writeText "main.go" ''
package main
import "os"
import "git.gensokyo.uk/security/fortify/test/sandbox"
func main() { (&sandbox.T{FS: os.DirFS("/")}).MustCheckFile(os.Args[1]) }
''} main.go
'';
}

View File

@ -1,4 +1,10 @@
lib: testProgram:
{
lib,
callPackage,
foot,
version,
}:
let
fs = mode: dir: data: {
mode = lib.fromHexString mode;
@ -23,6 +29,8 @@ let
;
};
checkSandbox = callPackage ../. { inherit version; };
callTestCase =
path:
let
@ -38,13 +46,9 @@ let
name = "check-sandbox-${tc.name}";
verbose = true;
inherit (tc) tty mapRealUid;
share = testProgram;
share = foot;
packages = [ ];
path = "${testProgram}/bin/fortify-test";
args = [
"test"
(toString (builtins.toFile "fortify-${tc.name}-want.json" (builtins.toJSON tc.want)))
];
command = builtins.toString (checkSandbox tc.name tc.want);
};
in
{

View File

@ -9,19 +9,6 @@
mapRealUid = true;
want = {
env = [
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus"
"HOME=/var/lib/fortify/u0/a3"
"PULSE_SERVER=unix:/run/user/1000/pulse/native"
"SHELL=/run/current-system/sw/bin/bash"
"TERM=linux"
"USER=u0_a3"
"WAYLAND_DISPLAY=wayland-0"
"XDG_RUNTIME_DIR=/run/user/1000"
"XDG_SESSION_CLASS=user"
"XDG_SESSION_TYPE=tty"
];
fs = fs "dead" {
".fortify" = fs "800001ed" {
etc = fs "800001ed" null null;
@ -97,6 +84,7 @@
"pki" = fs "80001ff" null null;
"polkit-1" = fs "80001ff" null null;
"profile" = fs "80001ff" null null;
"profiles" = fs "80001ff" null null;
"protocols" = fs "80001ff" null null;
"resolv.conf" = fs "80001ff" null null;
"resolvconf.conf" = fs "80001ff" null null;
@ -127,7 +115,7 @@
current-system = fs "80001ff" null null;
opengl-driver = fs "80001ff" null null;
user = fs "800001ed" {
"1000" = fs "800001c0" {
"1000" = fs "800001ed" {
bus = fs "10001fd" null null;
pulse = fs "800001c0" { native = fs "10001b6" null null; } null;
wayland-0 = fs "1000038" null null;
@ -215,7 +203,7 @@
(ent "/dri" "/dev/dri" "rw,nosuid" "devtmpfs" "devtmpfs" ignore)
(ent "/etc" "/.fortify/etc" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/" "/run/user" "rw,nosuid,nodev,relatime" "tmpfs" "tmpfs" "rw,size=4k,mode=755,uid=1000003,gid=1000003")
(ent "/" "/run/user/1000" "rw,nosuid,nodev,relatime" "tmpfs" "tmpfs" "rw,size=8192k,mode=700,uid=1000003,gid=1000003")
(ent "/" "/run/user/1000" "rw,nosuid,nodev,relatime" "tmpfs" "tmpfs" "rw,size=8192k,mode=755,uid=1000003,gid=1000003")
(ent "/tmp/fortify.1000/tmpdir/3" "/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/lib/fortify/u0/a3" "/var/lib/fortify/u0/a3" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/etc/passwd" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=1000003,gid=1000003")

View File

@ -9,19 +9,6 @@
mapRealUid = false;
want = {
env = [
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus"
"HOME=/var/lib/fortify/u0/a1"
"PULSE_SERVER=unix:/run/user/65534/pulse/native"
"SHELL=/run/current-system/sw/bin/bash"
"TERM=linux"
"USER=u0_a1"
"WAYLAND_DISPLAY=wayland-0"
"XDG_RUNTIME_DIR=/run/user/65534"
"XDG_SESSION_CLASS=user"
"XDG_SESSION_TYPE=tty"
];
fs = fs "dead" {
".fortify" = fs "800001ed" {
etc = fs "800001ed" null null;
@ -97,6 +84,7 @@
"pki" = fs "80001ff" null null;
"polkit-1" = fs "80001ff" null null;
"profile" = fs "80001ff" null null;
"profiles" = fs "80001ff" null null;
"protocols" = fs "80001ff" null null;
"resolv.conf" = fs "80001ff" null null;
"resolvconf.conf" = fs "80001ff" null null;
@ -127,7 +115,7 @@
current-system = fs "80001ff" null null;
opengl-driver = fs "80001ff" null null;
user = fs "800001ed" {
"65534" = fs "800001c0" {
"65534" = fs "800001ed" {
bus = fs "10001fd" null null;
pulse = fs "800001c0" { native = fs "10001b6" null null; } null;
wayland-0 = fs "1000038" null null;
@ -215,7 +203,7 @@
(ent "/dri" "/dev/dri" "rw,nosuid" "devtmpfs" "devtmpfs" ignore)
(ent "/etc" "/.fortify/etc" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/" "/run/user" "rw,nosuid,nodev,relatime" "tmpfs" "tmpfs" "rw,size=4k,mode=755,uid=1000001,gid=1000001")
(ent "/" "/run/user/65534" "rw,nosuid,nodev,relatime" "tmpfs" "tmpfs" "rw,size=8192k,mode=700,uid=1000001,gid=1000001")
(ent "/" "/run/user/65534" "rw,nosuid,nodev,relatime" "tmpfs" "tmpfs" "rw,size=8192k,mode=755,uid=1000001,gid=1000001")
(ent "/tmp/fortify.1000/tmpdir/1" "/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/lib/fortify/u0/a1" "/var/lib/fortify/u0/a1" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/etc/passwd" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=1000001,gid=1000001")

View File

@ -9,19 +9,6 @@
mapRealUid = false;
want = {
env = [
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus"
"HOME=/var/lib/fortify/u0/a2"
"PULSE_SERVER=unix:/run/user/65534/pulse/native"
"SHELL=/run/current-system/sw/bin/bash"
"TERM=linux"
"USER=u0_a2"
"WAYLAND_DISPLAY=wayland-0"
"XDG_RUNTIME_DIR=/run/user/65534"
"XDG_SESSION_CLASS=user"
"XDG_SESSION_TYPE=tty"
];
fs = fs "dead" {
".fortify" = fs "800001ed" {
etc = fs "800001ed" null null;
@ -98,6 +85,7 @@
"pki" = fs "80001ff" null null;
"polkit-1" = fs "80001ff" null null;
"profile" = fs "80001ff" null null;
"profiles" = fs "80001ff" null null;
"protocols" = fs "80001ff" null null;
"resolv.conf" = fs "80001ff" null null;
"resolvconf.conf" = fs "80001ff" null null;
@ -128,7 +116,7 @@
current-system = fs "80001ff" null null;
opengl-driver = fs "80001ff" null null;
user = fs "800001ed" {
"65534" = fs "800001c0" {
"65534" = fs "800001ed" {
bus = fs "10001fd" null null;
pulse = fs "800001c0" { native = fs "10001b6" null null; } null;
wayland-0 = fs "1000038" null null;
@ -217,7 +205,7 @@
(ent "/dri" "/dev/dri" "rw,nosuid" "devtmpfs" "devtmpfs" ignore)
(ent "/etc" "/.fortify/etc" "ro,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/" "/run/user" "rw,nosuid,nodev,relatime" "tmpfs" "tmpfs" "rw,size=4k,mode=755,uid=1000002,gid=1000002")
(ent "/" "/run/user/65534" "rw,nosuid,nodev,relatime" "tmpfs" "tmpfs" "rw,size=8192k,mode=700,uid=1000002,gid=1000002")
(ent "/" "/run/user/65534" "rw,nosuid,nodev,relatime" "tmpfs" "tmpfs" "rw,size=8192k,mode=755,uid=1000002,gid=1000002")
(ent "/tmp/fortify.1000/tmpdir/2" "/tmp" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent "/var/lib/fortify/u0/a2" "/var/lib/fortify/u0/a2" "rw,nosuid,nodev,relatime" "ext4" "/dev/disk/by-label/nixos" "rw")
(ent ignore "/etc/passwd" "ro,nosuid,nodev,relatime" "tmpfs" "rootfs" "rw,uid=1000002,gid=1000002")

View File

@ -1,76 +0,0 @@
{
lib,
pkgs,
config,
...
}:
let
testProgram = pkgs.callPackage ./tool/package.nix { inherit (config.environment.fortify.package) version; };
testCases = import ./case lib testProgram;
in
{
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 = {
systemPackages = with pkgs; [
# For checking seccomp outcome:
testProgram
];
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.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 performance:
"-smp 8"
];
environment.fortify = {
enable = true;
stateDir = "/var/lib/fortify";
users.alice = 0;
home-manager = _: _: { home.stateVersion = "23.05"; };
apps = [
testCases.preset
testCases.tty
testCases.mapuid
];
};
}

View File

@ -1,39 +1,14 @@
{
lib,
nixosTest,
writeShellScript,
writeText,
callPackage,
self,
withRace ? false,
version,
}:
nixosTest {
name = "fortify-sandbox" + (if withRace then "-race" else "");
nodes.machine =
{ options, pkgs, ... }:
{
# Run with Go race detector:
environment.fortify = lib.mkIf withRace rec {
# race detector does not support static linking
package = (pkgs.callPackage ../../package.nix { }).overrideAttrs (previousAttrs: {
GOFLAGS = previousAttrs.GOFLAGS ++ [ "-race" ];
});
fsuPackage = options.environment.fortify.fsuPackage.default.override { fortify = package; };
};
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;
}
name: want:
writeShellScript "fortify-${name}-check-sandbox-script" ''
set -e
${callPackage ./assert.nix { inherit version; }}/bin/test \
${writeText "fortify-${name}-want.json" (builtins.toJSON want)}
touch /tmp/sandbox-ok
''

View File

@ -1,119 +0,0 @@
package sandbox
import (
"bufio"
"fmt"
"io"
"os"
"strings"
"syscall"
"time"
"unsafe"
)
const (
NULL = 0
PTRACE_ATTACH = 16
PTRACE_DETACH = 17
PTRACE_SECCOMP_GET_FILTER = 0x420c
)
type ptraceError struct {
op string
errno syscall.Errno
}
func (p *ptraceError) Error() string { return fmt.Sprintf("%s: %v", p.op, p.errno) }
func (p *ptraceError) Unwrap() error {
if p.errno == 0 {
return nil
}
return p.errno
}
func ptrace(op uintptr, pid, addr int, data unsafe.Pointer) (r uintptr, errno syscall.Errno) {
r, _, errno = syscall.Syscall6(syscall.SYS_PTRACE, op, uintptr(pid), uintptr(addr), uintptr(data), NULL, NULL)
return
}
func ptraceAttach(pid int) error {
const (
statePrefix = "State:"
stateSuffix = "t (tracing stop)"
)
var r io.ReadSeekCloser
if f, err := os.Open(fmt.Sprintf("/proc/%d/status", pid)); err != nil {
return err
} else {
r = f
}
if _, errno := ptrace(PTRACE_ATTACH, pid, 0, nil); errno != 0 {
return &ptraceError{"PTRACE_ATTACH", errno}
}
// ugly! but there does not appear to be another way
for {
time.Sleep(10 * time.Millisecond)
if _, err := r.Seek(0, io.SeekStart); err != nil {
return err
}
s := bufio.NewScanner(r)
var found bool
for s.Scan() {
found = strings.HasPrefix(s.Text(), statePrefix)
if found {
break
}
}
if err := s.Err(); err != nil {
return err
}
if !found {
return syscall.EBADE
}
if strings.HasSuffix(s.Text(), stateSuffix) {
break
}
}
return nil
}
func ptraceDetach(pid int) error {
if _, errno := ptrace(PTRACE_DETACH, pid, 0, nil); errno != 0 {
return &ptraceError{"PTRACE_DETACH", errno}
}
return nil
}
type sockFilter struct { /* Filter block */
code uint16 /* Actual filter code */
jt uint8 /* Jump true */
jf uint8 /* Jump false */
k uint32 /* Generic multiuse field */
}
func getFilter[T comparable](pid, index int) ([]T, error) {
if s := unsafe.Sizeof(*new(T)); s != 8 {
panic(fmt.Sprintf("invalid filter block size %d", s))
}
var buf []T
if n, errno := ptrace(PTRACE_SECCOMP_GET_FILTER, pid, index, nil); errno != 0 {
return nil, &ptraceError{"PTRACE_SECCOMP_GET_FILTER", errno}
} else {
buf = make([]T, n)
}
if _, errno := ptrace(PTRACE_SECCOMP_GET_FILTER, pid, index, unsafe.Pointer(&buf[0])); errno != 0 {
return nil, &ptraceError{"PTRACE_SECCOMP_GET_FILTER", errno}
}
return buf, nil
}

View File

@ -10,7 +10,9 @@ import (
*/
import "C"
func trySyscalls() error {
const NULL = 0
func TrySyscalls() error {
testCases := []struct {
name string
errno syscall.Errno

View File

@ -1,71 +0,0 @@
import json
import shlex
q = shlex.quote
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
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")
# Check seccomp outcome:
swaymsg("exec fortify run cat")
pid = int(machine.wait_until_succeeds("pgrep -U 1000000 -x cat", timeout=5))
print(machine.succeed(f"fortify-test filter {pid} c698b081ff957afe17a6d94374537d37f2a63f6f9dd75da7546542407a9e32476ebda3312ba7785d7f618542bcfaf27ca27dcc2dddba852069d28bcfe8cad39a &>/dev/stdout", timeout=5))
machine.succeed(f"kill -TERM {pid}")
# Verify capabilities/securebits in user namespace:
print(machine.succeed("sudo -u alice -i fortify run capsh --print"))
print(machine.succeed("sudo -u alice -i fortify run capsh --has-no-new-privs"))
print(machine.fail("sudo -u alice -i fortify run capsh --has-a=CAP_SYS_ADMIN"))
print(machine.fail("sudo -u alice -i fortify run capsh --has-b=CAP_SYS_ADMIN"))
print(machine.fail("sudo -u alice -i fortify run capsh --has-i=CAP_SYS_ADMIN"))
print(machine.fail("sudo -u alice -i fortify run capsh --has-p=CAP_SYS_ADMIN"))
print(machine.fail("sudo -u alice -i fortify run umount -R /dev"))
# Check sandbox outcome:
check_offset = 0
def check_sandbox(name):
global check_offset
check_offset += 1
swaymsg(f"exec script /dev/null -E always -qec check-sandbox-{name}")
machine.wait_for_file(f"/tmp/fortify.1000/tmpdir/{check_offset}/sandbox-ok", timeout=15)
check_sandbox("preset")
check_sandbox("tty")
check_sandbox("mapuid")
# 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,39 +0,0 @@
package main
import (
"log"
"os"
"strconv"
"strings"
"git.gensokyo.uk/security/fortify/test/sandbox"
)
func main() {
log.SetFlags(0)
log.SetPrefix("test: ")
if len(os.Args) < 2 {
log.Fatal("invalid argument")
}
switch os.Args[1] {
case "filter":
if len(os.Args) != 4 {
log.Fatal("invalid argument")
}
if pid, err := strconv.Atoi(strings.TrimSpace(os.Args[2])); err != nil {
log.Fatalf("%s", err)
} else if pid < 1 {
log.Fatalf("%d out of range", pid)
} else {
sandbox.MustCheckFilter(pid, os.Args[3])
return
}
default:
(&sandbox.T{FS: os.DirFS("/")}).MustCheckFile(os.Args[1], "/tmp/sandbox-ok")
return
}
}

View File

@ -1,30 +0,0 @@
{
lib,
buildGoModule,
pkg-config,
util-linux,
version,
}:
buildGoModule rec {
pname = "check-sandbox";
inherit version;
src = builtins.path {
name = "${pname}-src";
path = lib.cleanSource ../.;
filter = path: type: (type == "directory") || (type == "regular" && lib.hasSuffix ".go" path);
};
vendorHash = null;
buildInputs = [ util-linux ];
nativeBuildInputs = [ pkg-config ];
preBuild = ''
go mod init git.gensokyo.uk/security/fortify/test/sandbox >& /dev/null
'';
postInstall = ''
mv $out/bin/tool $out/bin/fortify-test
'';
}

View File

@ -105,9 +105,19 @@ if denyOutput != "fsu: uid 1001 is not in the fsurc file\n":
if denyOutputVerbose != "fsu: uid 1001 is not in the fsurc file\nfortify: *cannot obtain uid from fsu: permission denied\n":
raise Exception(f"unexpected deny verbose output:\n{denyOutputVerbose}")
# Check sandbox outcome:
check_offset = 0
def check_sandbox(name):
global check_offset
check_offset += 1
swaymsg(f"exec script /dev/null -E always -qec check-sandbox-{name}")
machine.wait_for_file(f"/tmp/fortify.1000/tmpdir/{check_offset}/sandbox-ok", timeout=15)
check_sandbox("preset")
check_sandbox("tty")
check_sandbox("mapuid")
def aid(offset):
return 1+check_offset+offset
@ -176,11 +186,25 @@ machine.send_chars("clear; wayland-info && touch /tmp/client-ok\n")
machine.wait_for_file(tmpdir_path(0, "client-ok"), timeout=15)
collect_state_ui("foot_wayland")
check_state("ne-foot", 1)
# Verify lack of acl on XDG_RUNTIME_DIR:
machine.fail(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {aid(0) + 1000000}")
# Verify acl on XDG_RUNTIME_DIR:
print(machine.succeed(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {aid(0) + 1000000}"))
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep foot", timeout=5)
machine.fail(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {aid(0) + 1000000}", timeout=5)
# Verify acl cleanup on XDG_RUNTIME_DIR:
machine.wait_until_fails(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {aid(0) + 1000000}", timeout=5)
# Start app (foot) with Wayland enablement from a terminal:
swaymsg("exec foot $SHELL -c '(ne-foot) & sleep 1 && fortify show $(fortify ps --short) && touch /tmp/ps-show-ok && cat'")
wait_for_window(f"u0_a{aid(0)}@machine")
machine.send_chars("clear; wayland-info && touch /tmp/term-ok\n")
machine.wait_for_file(tmpdir_path(0, "term-ok"), timeout=15)
machine.wait_for_file("/tmp/ps-show-ok", timeout=5)
collect_state_ui("foot_wayland_term")
check_state("ne-foot", 1)
machine.send_chars("exit\n")
wait_for_window("foot")
machine.send_key("ctrl-c")
machine.wait_until_fails("pgrep foot", timeout=5)
# Test PulseAudio (fortify does not support PipeWire yet):
swaymsg("exec pa-foot")
@ -219,22 +243,6 @@ machine.wait_until_fails(f"getfacl --absolute-names --omit-header --numeric /run
# Test syscall filter:
print(machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 strace-failure"))
# Start app (foot) with Wayland enablement from a terminal:
swaymsg("exec foot $SHELL -c '(ne-foot) & disown && exec $SHELL'")
wait_for_window(f"u0_a{aid(0)}@machine")
machine.send_chars("clear; wayland-info && touch /tmp/term-ok\n")
machine.wait_for_file(tmpdir_path(0, "term-ok"), timeout=15)
machine.send_key("alt-h")
machine.send_chars("clear; fortify show $(fortify ps --short) && touch /tmp/ps-show-ok && exec cat\n")
machine.wait_for_file("/tmp/ps-show-ok", timeout=5)
collect_state_ui("foot_wayland_term")
check_state("ne-foot", 1)
machine.send_key("alt-l")
machine.send_chars("exit\n")
wait_for_window("alice@machine")
machine.send_key("ctrl-c")
machine.wait_until_fails("pgrep foot", timeout=5)
# Exit Sway and verify process exit status 0:
swaymsg("exit", succeed=False)
machine.wait_for_file("/tmp/sway-exit-ok")