Compare commits

..

No commits in common. "48f634d04634f9b4051fec416ca9e08cf36308f6" and "5c82f1ed3eb21d6a6999748ca19d26cefaf7598c" have entirely different histories.

32 changed files with 161 additions and 654 deletions

View File

@ -22,57 +22,6 @@ jobs:
path: result/* path: result/*
retention-days: 1 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: fpkg:
name: Fpkg name: Fpkg
runs-on: nix runs-on: nix
@ -90,14 +39,29 @@ jobs:
path: result/* path: result/*
retention-days: 1 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: check:
name: Flake checks name: Flake checks
needs: needs:
- fortify - fortify
- race
- sandbox
- sandbox-race
- fpkg - fpkg
- race
runs-on: nix runs-on: nix
steps: steps:
- name: Checkout - name: Checkout

View File

@ -8,7 +8,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"syscall"
"testing" "testing"
"time" "time"
@ -72,7 +71,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)

View File

@ -58,19 +58,12 @@
in in
{ {
fortify = callPackage ./test { inherit system self; }; fortify = callPackage ./test { inherit system self; };
fpkg = callPackage ./cmd/fpkg/test { inherit system self; };
race = callPackage ./test { race = callPackage ./test {
inherit system self; inherit system self;
withRace = true; withRace = true;
}; };
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 ]; } '' formatting = runCommandLocal "check-formatting" { nativeBuildInputs = [ nixfmt-rfc-style ]; } ''
cd ${./.} cd ${./.}

View File

@ -97,10 +97,6 @@ func (s *SandboxConfig) ToContainer(sys SandboxSys, uid, gid *int) (*sandbox.Par
Seccomp: s.Seccomp, Seccomp: s.Seccomp,
} }
if s.Multiarch {
container.Seccomp |= seccomp.FlagMultiarch
}
/* this is only 4 KiB of memory on a 64-bit system, /* this is only 4 KiB of memory on a 64-bit system,
permissive defaults on NixOS results in around 100 entries permissive defaults on NixOS results in around 100 entries
so this capacity should eliminate copies for most setups */ so this capacity should eliminate copies for most setups */

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)
} }
}() }()

View File

@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"maps"
"os" "os"
"path" "path"
"regexp" "regexp"
@ -504,13 +505,7 @@ func (seal *outcome) finalise(ctx context.Context, sys sys.State, config *fst.Co
// flatten and sort env for deterministic behaviour // flatten and sort env for deterministic behaviour
seal.container.Env = make([]string, 0, len(seal.env)) seal.container.Env = make([]string, 0, len(seal.env))
for k, v := range seal.env { maps.All(seal.env)(func(k string, v string) bool { seal.container.Env = append(seal.container.Env, k+"="+v); return true })
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)
}
slices.Sort(seal.container.Env) slices.Sort(seal.container.Env)
fmsg.Verbosef("created application seal for uid %s (%s) groups: %v, argv: %s", fmsg.Verbosef("created application seal for uid %s (%s) groups: %v, 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 // load or initialise new backend
b := new(multiBackend) b := new(multiBackend)
b.lock.Lock()
if v, ok := s.backends.LoadOrStore(aid, b); ok { if v, ok := s.backends.LoadOrStore(aid, b); ok {
b = v.(*multiBackend) b = v.(*multiBackend)
} else { } else {
b.lock.Lock()
b.path = path.Join(s.base, strconv.Itoa(aid)) b.path = path.Join(s.base, strconv.Itoa(aid))
// ensure directory // ensure directory

View File

@ -47,7 +47,7 @@ type State interface {
Uid(aid int) (int, error) 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) { func CopyPaths(os State, v *fst.Paths) {
v.SharePath = path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Getuid())) v.SharePath = path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Getuid()))

View File

@ -201,11 +201,9 @@ in
${copy "${pkg}/share/icons"} ${copy "${pkg}/share/icons"}
${copy "${pkg}/share/man"} ${copy "${pkg}/share/man"}
if test -d "$out/share/applications"; then
substituteInPlace $out/share/applications/* \ substituteInPlace $out/share/applications/* \
--replace-warn '${pkg}/bin/' "" \ --replace-warn '${pkg}/bin/' "" \
--replace-warn '${pkg}/libexec/' "" --replace-warn '${pkg}/libexec/' ""
fi
'' ''
) )
++ acc ++ acc

View File

@ -35,7 +35,7 @@ package
*Default:* *Default:*
` <derivation fortify-static-x86_64-unknown-linux-musl-0.3.2> ` ` <derivation fortify-static-x86_64-unknown-linux-musl-0.3.1> `
@ -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 ## 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 ## environment\.fortify\.apps\.\*\.script
@ -644,7 +606,7 @@ package
*Default:* *Default:*
` <derivation fortify-fsu-0.3.2> ` ` <derivation fortify-fsu-0.3.1> `

View File

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

View File

@ -99,8 +99,6 @@ type (
// Permission bits of newly created parent directories. // Permission bits of newly created parent directories.
// The zero value is interpreted as 0755. // The zero value is interpreted as 0755.
ParentPerm os.FileMode ParentPerm os.FileMode
// Retain CAP_SYS_ADMIN.
Privileged bool
Flags HardeningFlags Flags HardeningFlags
} }

View File

@ -223,30 +223,17 @@ func Init(prepare func(prefix string), setVerbose func(verbose bool)) {
if _, _, errno := syscall.Syscall(PR_SET_NO_NEW_PRIVS, 1, 0, 0); errno != 0 { if _, _, errno := syscall.Syscall(PR_SET_NO_NEW_PRIVS, 1, 0, 0); errno != 0 {
log.Fatalf("prctl(PR_SET_NO_NEW_PRIVS): %v", errno) log.Fatalf("prctl(PR_SET_NO_NEW_PRIVS): %v", errno)
} }
if _, _, errno := syscall.Syscall(syscall.SYS_PRCTL, PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0); errno != 0 { 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) log.Fatalf("cannot clear the ambient capability set: %v", errno)
} }
for i := uintptr(0); i <= LastCap(); i++ { 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 { 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) 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( if err := capset(
&capHeader{_LINUX_CAPABILITY_VERSION_3, 0}, &capHeader{_LINUX_CAPABILITY_VERSION_3, 0},
&[2]capData{{0, keep[0], keep[0]}, {0, keep[1], keep[1]}}, &[2]capData{{0, 0, 0}, {0, 0, 0}},
); err != nil { ); err != nil {
log.Fatalf("cannot capset: %v", err) log.Fatalf("cannot capset: %v", err)
} }

View File

@ -73,16 +73,6 @@ func TestExport(t *testing.T) {
0x80, 0x8b, 0x1a, 0x6f, 0x84, 0xf3, 0x2b, 0xbd, 0x80, 0x8b, 0x1a, 0x6f, 0x84, 0xf3, 0x2b, 0xbd,
0xe1, 0xaa, 0x02, 0xae, 0x30, 0xee, 0xdc, 0xfa, 0xe1, 0xaa, 0x02, 0xae, 0x30, 0xee, 0xdc, 0xfa,
}, false}, }, 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) buf := make([]byte, 8)

View File

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

View File

@ -11,7 +11,6 @@ const (
PR_SET_NO_NEW_PRIVS = 0x26 PR_SET_NO_NEW_PRIVS = 0x26
CAP_SYS_ADMIN = 0x15 CAP_SYS_ADMIN = 0x15
CAP_SETPCAP = 0x8
) )
const ( const (
@ -31,9 +30,10 @@ func SetDumpable(dumpable uintptr) error {
const ( const (
_LINUX_CAPABILITY_VERSION_3 = 0x20080522 _LINUX_CAPABILITY_VERSION_3 = 0x20080522
PR_CAP_AMBIENT = 0x2f PR_CAP_AMBIENT = 47
PR_CAP_AMBIENT_RAISE = 0x2 PR_CAP_AMBIENT_CLEAR_ALL = 4
PR_CAP_AMBIENT_CLEAR_ALL = 0x4
CAP_SETPCAP = 8
) )
type ( type (
@ -49,12 +49,6 @@ type (
} }
) )
// 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 { func capset(hdrp *capHeader, datap *[2]capData) error {
if _, _, errno := syscall.Syscall(syscall.SYS_CAPSET, if _, _, errno := syscall.Syscall(syscall.SYS_CAPSET,
uintptr(unsafe.Pointer(hdrp)), uintptr(unsafe.Pointer(hdrp)),

View File

@ -4,6 +4,17 @@
config, config,
... ...
}: }:
let
testCases = import ./sandbox/case {
inherit (pkgs)
lib
callPackage
writeText
foot
;
inherit (config.environment.fortify.package) version;
};
in
{ {
users.users = { users.users = {
alice = { alice = {
@ -102,6 +113,10 @@
home-manager = _: _: { home.stateVersion = "23.05"; }; home-manager = _: _: { home.stateVersion = "23.05"; };
apps = [ apps = [
testCases.preset
testCases.tty
testCases.mapuid
{ {
name = "ne-foot"; name = "ne-foot";
verbose = true; 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 package sandbox
import ( import (
"crypto/sha512"
"encoding/hex"
"encoding/json" "encoding/json"
"errors"
"io/fs" "io/fs"
"log" "log"
"os" "os"
"syscall"
"time"
) )
var ( var (
@ -121,7 +116,7 @@ func (t *T) MustCheck(want *TestCase) {
} }
if want.Seccomp { if want.Seccomp {
if trySyscalls() != nil { if TrySyscalls() != nil {
os.Exit(1) os.Exit(1)
} }
} else { } else {
@ -129,81 +124,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) { func mustDecode(wantFilePath string, v any) {
if f, err := os.Open(wantFilePath); err != nil { if f, err := os.Open(wantFilePath); err != nil {
fatalf("cannot open %q: %v", wantFilePath, err) 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], "/tmp/sandbox-ok") }
''} main.go
'';
}

View File

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

View File

@ -97,6 +97,7 @@
"pki" = fs "80001ff" null null; "pki" = fs "80001ff" null null;
"polkit-1" = fs "80001ff" null null; "polkit-1" = fs "80001ff" null null;
"profile" = fs "80001ff" null null; "profile" = fs "80001ff" null null;
"profiles" = fs "80001ff" null null;
"protocols" = fs "80001ff" null null; "protocols" = fs "80001ff" null null;
"resolv.conf" = fs "80001ff" null null; "resolv.conf" = fs "80001ff" null null;
"resolvconf.conf" = fs "80001ff" null null; "resolvconf.conf" = fs "80001ff" null null;

View File

@ -97,6 +97,7 @@
"pki" = fs "80001ff" null null; "pki" = fs "80001ff" null null;
"polkit-1" = fs "80001ff" null null; "polkit-1" = fs "80001ff" null null;
"profile" = fs "80001ff" null null; "profile" = fs "80001ff" null null;
"profiles" = fs "80001ff" null null;
"protocols" = fs "80001ff" null null; "protocols" = fs "80001ff" null null;
"resolv.conf" = fs "80001ff" null null; "resolv.conf" = fs "80001ff" null null;
"resolvconf.conf" = fs "80001ff" null null; "resolvconf.conf" = fs "80001ff" null null;

View File

@ -98,6 +98,7 @@
"pki" = fs "80001ff" null null; "pki" = fs "80001ff" null null;
"polkit-1" = fs "80001ff" null null; "polkit-1" = fs "80001ff" null null;
"profile" = fs "80001ff" null null; "profile" = fs "80001ff" null null;
"profiles" = fs "80001ff" null null;
"protocols" = fs "80001ff" null null; "protocols" = fs "80001ff" null null;
"resolv.conf" = fs "80001ff" null null; "resolv.conf" = fs "80001ff" null null;
"resolvconf.conf" = fs "80001ff" null null; "resolvconf.conf" = fs "80001ff" null null;

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 +0,0 @@
{
lib,
nixosTest,
self,
withRace ? false,
}:
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;
}

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" import "C"
func trySyscalls() error { const NULL = 0
func TrySyscalls() error {
testCases := []struct { testCases := []struct {
name string name string
errno syscall.Errno 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

@ -99,15 +99,34 @@ print(denyOutputVerbose)
# Fail direct fsu call: # Fail direct fsu call:
print(machine.fail("sudo -u alice -i fsu")) print(machine.fail("sudo -u alice -i fsu"))
# 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"))
# Verify PrintBaseError behaviour: # Verify PrintBaseError behaviour:
if denyOutput != "fsu: uid 1001 is not in the fsurc file\n": if denyOutput != "fsu: uid 1001 is not in the fsurc file\n":
raise Exception(f"unexpected deny output:\n{denyOutput}") raise Exception(f"unexpected deny output:\n{denyOutput}")
if denyOutputVerbose != "fsu: uid 1001 is not in the fsurc file\nfortify: *cannot obtain uid from fsu: permission denied\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}") raise Exception(f"unexpected deny verbose output:\n{denyOutputVerbose}")
# Check sandbox outcome:
check_offset = 0 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): def aid(offset):
return 1+check_offset+offset return 1+check_offset+offset