Compare commits

..

No commits in common. "2e7e16068372a0c0b42bd6b3da2433134c91a495" and "9d9a165379798acb237c9071ea865f26cfba8be7" have entirely different histories.

34 changed files with 736 additions and 1664 deletions

View File

@ -12,17 +12,16 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Run fortify tests
run: nix build --out-link "result-fortify" --print-out-paths --print-build-logs .#checks.x86_64-linux.fortify
- name: Run flake checks
run: nix --print-build-logs --experimental-features 'nix-command flakes' flake check
- name: Run tests
run: |
nix --print-build-logs --experimental-features 'nix-command flakes' flake check
nix build --out-link "result" --print-out-paths --print-build-logs .#checks.x86_64-linux.nixos-tests
- name: Upload test output
uses: actions/upload-artifact@v3
with:
name: "fortify-vm-output"
path: result-fortify/*
name: "nixos-vm-output"
path: result/*
retention-days: 1
dist:

View File

@ -8,7 +8,6 @@
{
lib,
writeScript,
writeScriptBin,
runtimeShell,
writeText,
symlinkJoin,
@ -178,7 +177,7 @@ let
};
in
writeScriptBin "build-fpkg-${pname}" ''
writeScript "fortify-${pname}-bundle-prelude" ''
#!${runtimeShell} -el
OUT="$(mktemp -d)"
TAR="$(mktemp -u)"

View File

@ -9,10 +9,10 @@ import (
"git.gensokyo.uk/security/fortify/internal/fmsg"
)
const shellPath = "/run/current-system/sw/bin/bash"
const shell = "/run/current-system/sw/bin/bash"
func init() {
if err := os.Setenv("SHELL", shellPath); err != nil {
if err := os.Setenv("SHELL", shell); err != nil {
log.Fatalf("cannot set $SHELL: %v", err)
}
}

View File

@ -80,7 +80,7 @@ func actionStart(args []string) {
if !dropShell {
command[0] = app.Launcher
} else {
command[0] = shellPath
command[0] = shell
}
command = append(command, args[1:]...)

View File

@ -15,7 +15,7 @@ func withNixDaemon(
) {
fortifyAppDropShell(updateConfig(&fst.Config{
ID: app.ID,
Command: []string{shellPath, "-lc", "rm -f /nix/var/nix/daemon-socket/socket && " +
Command: []string{shell, "-lc", "rm -f /nix/var/nix/daemon-socket/socket && " +
// start nix-daemon
"nix-daemon --store / & " +
// wait for socket to appear
@ -59,7 +59,7 @@ func withNixDaemon(
func withCacheDir(action string, command []string, workDir string, app *bundleInfo, pathSet *appPathSet, dropShell bool, beforeFail func()) {
fortifyAppDropShell(&fst.Config{
ID: app.ID,
Command: []string{shellPath, "-lc", strings.Join(command, " && ")},
Command: []string{shell, "-lc", strings.Join(command, " && ")},
Confinement: fst.ConfinementConfig{
AppID: app.AppID,
Username: "nixos",
@ -92,7 +92,7 @@ func withCacheDir(action string, command []string, workDir string, app *bundleIn
func fortifyAppDropShell(config *fst.Config, dropShell bool, beforeFail func()) {
if dropShell {
config.Command = []string{shellPath, "-l"}
config.Command = []string{shell, "-l"}
fortifyApp(config, beforeFail)
beforeFail()
internal.Exit(0)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,7 +32,7 @@
buildPackage = forAllSystems (
system:
nixpkgsFor.${system}.callPackage (
import ./cmd/fpkg/build.nix {
import ./bundle.nix {
inherit
nixpkgsFor
system
@ -57,16 +57,18 @@
;
in
{
formatting = runCommandLocal "check-formatting" { nativeBuildInputs = [ nixfmt-rfc-style ]; } ''
cd ${./.}
check-formatting =
runCommandLocal "check-formatting" { nativeBuildInputs = [ nixfmt-rfc-style ]; }
''
cd ${./.}
echo "running nixfmt..."
nixfmt --check .
echo "running nixfmt..."
nixfmt --check .
touch $out
'';
touch $out
'';
lint =
check-lint =
runCommandLocal "check-lint"
{
nativeBuildInputs = [
@ -86,7 +88,10 @@
touch $out
'';
fortify = callPackage ./tests/fortify { inherit system self; };
nixos-tests = callPackage ./test.nix {
inherit system self home-manager;
inherit (self.packages.${system}) fortify;
};
}
);

View File

@ -1,4 +1,3 @@
// Package fst exports shared fortify types.
package fst
import (
@ -7,22 +6,16 @@ import (
)
type App interface {
// ID returns a copy of [fst.ID] held by App.
// ID returns a copy of App's unique ID.
ID() ID
// Run sets up the system and runs the App.
Run(ctx context.Context, rs *RunState) error
// Seal determines the outcome of config as a [SealedApp].
// The value of config might be overwritten and must not be used again.
Seal(config *Config) (SealedApp, error)
Seal(config *Config) error
String() string
}
type SealedApp interface {
// Run commits sealed system setup and starts the app process.
Run(ctx context.Context, rs *RunState) error
}
// RunState stores the outcome of a call to [SealedApp.Run].
// RunState stores the outcome of a call to [App.Run].
type RunState struct {
// Time is the exact point in time where the process was created.
// Location must be set to UTC.

2
fst/shared.go Normal file
View File

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

View File

@ -2,7 +2,6 @@ package app
import (
"fmt"
"log"
"sync"
"git.gensokyo.uk/security/fortify/fst"
@ -21,23 +20,15 @@ func New(os sys.State) (fst.App, error) {
return a, err
}
func MustNew(os sys.State) fst.App {
a, err := New(os)
if err != nil {
log.Fatalf("cannot create app: %v", err)
}
return a
}
type app struct {
id *stringPair[fst.ID]
sys sys.State
*outcome
*appSeal
mu sync.RWMutex
}
func (a *app) ID() fst.ID { a.mu.RLock(); defer a.mu.RUnlock(); return a.id.unwrap() }
func (a *app) ID() fst.ID { return a.id.unwrap() }
func (a *app) String() string {
if a == nil {
@ -47,33 +38,32 @@ func (a *app) String() string {
a.mu.RLock()
defer a.mu.RUnlock()
if a.outcome != nil {
if a.outcome.user.uid == nil {
if a.appSeal != nil {
if a.appSeal.user.uid == nil {
return fmt.Sprintf("(sealed app %s with invalid uid)", a.id)
}
return fmt.Sprintf("(sealed app %s as uid %s)", a.id, a.outcome.user.uid)
return fmt.Sprintf("(sealed app %s as uid %s)", a.id, a.appSeal.user.uid)
}
return fmt.Sprintf("(unsealed app %s)", a.id)
}
func (a *app) Seal(config *fst.Config) (fst.SealedApp, error) {
func (a *app) Seal(config *fst.Config) (err error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.outcome != nil {
if a.appSeal != nil {
panic("app sealed twice")
}
if config == nil {
return nil, fmsg.WrapError(ErrConfig,
return fmsg.WrapError(ErrConfig,
"attempted to seal app with nil config")
}
seal := new(outcome)
seal.id = a.id
err := seal.finalise(a.sys, config)
seal := new(appSeal)
err = seal.finalise(a.sys, config, a.id.String())
if err == nil {
a.outcome = seal
a.appSeal = seal
}
return seal, err
return
}

View File

@ -29,21 +29,17 @@ func TestApp(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
a := app.NewWithID(tc.id, tc.os)
var (
gotSys *system.I
gotBwrap *bwrap.Config
)
if !t.Run("seal", func(t *testing.T) {
if sa, err := a.Seal(tc.config); err != nil {
if err := a.Seal(tc.config); err != nil {
t.Errorf("Seal: error = %v", err)
return
} else {
gotSys, gotBwrap = app.AppSystemBwrap(a, sa)
}
}) {
return
}
gotSys, gotBwrap := app.AppSystemBwrap(a)
t.Run("compare sys", func(t *testing.T) {
if !gotSys.Equal(tc.wantSys) {
t.Errorf("Seal: sys = %#v, want %#v",

View File

@ -14,11 +14,7 @@ func NewWithID(id fst.ID, os sys.State) fst.App {
return a
}
func AppSystemBwrap(a fst.App, sa fst.SealedApp) (*system.I, *bwrap.Config) {
func AppSystemBwrap(a fst.App) (*system.I, *bwrap.Config) {
v := a.(*app)
seal := sa.(*outcome)
if v.outcome != seal || v.id != seal.id {
panic("broken app/outcome link")
}
return seal.sys, seal.container
return v.appSeal.sys, v.appSeal.container
}

View File

@ -20,16 +20,12 @@ import (
const shimSetupTimeout = 5 * time.Second
func (seal *outcome) Run(ctx context.Context, rs *fst.RunState) error {
if !seal.f.CompareAndSwap(false, true) {
// run does much more than just starting a process; calling it twice, even if the first call fails, will result
// in inconsistent state that is impossible to clean up; return here to limit damage and hopefully give the
// other Run a chance to return
panic("attempted to run twice")
}
func (a *app) Run(ctx context.Context, rs *fst.RunState) error {
a.mu.Lock()
defer a.mu.Unlock()
if rs == nil {
panic("invalid state")
panic("attempted to pass nil state to run")
}
/*
@ -37,8 +33,8 @@ func (seal *outcome) Run(ctx context.Context, rs *fst.RunState) error {
*/
shimExec := [2]string{helper.BubblewrapName}
if len(seal.command) > 0 {
shimExec[1] = seal.command[0]
if len(a.appSeal.command) > 0 {
shimExec[1] = a.appSeal.command[0]
}
for i, n := range shimExec {
if len(n) == 0 {
@ -58,15 +54,15 @@ func (seal *outcome) Run(ctx context.Context, rs *fst.RunState) error {
prepare/revert os state
*/
if err := seal.sys.Commit(ctx); err != nil {
if err := a.appSeal.sys.Commit(ctx); err != nil {
return err
}
store := state.NewMulti(seal.runDirPath)
store := state.NewMulti(a.sys.Paths().RunDirPath)
deferredStoreFunc := func(c state.Cursor) error { return nil }
defer func() {
var revertErr error
storeErr := new(StateStoreError)
storeErr.Inner, storeErr.DoErr = store.Do(seal.user.aid.unwrap(), func(c state.Cursor) {
storeErr.Inner, storeErr.DoErr = store.Do(a.appSeal.user.aid.unwrap(), func(c state.Cursor) {
revertErr = func() error {
storeErr.InnerErr = deferredStoreFunc(c)
@ -79,7 +75,7 @@ func (seal *outcome) Run(ctx context.Context, rs *fst.RunState) error {
ec.Set(system.Process)
if states, err := c.Load(); err != nil {
// revert per-process state here to limit damage
return errors.Join(err, seal.sys.Revert(ec))
return errors.Join(err, a.appSeal.sys.Revert(ec))
} else {
if l := len(states); l == 0 {
fmsg.Verbose("no other launchers active, will clean up globals")
@ -115,7 +111,7 @@ func (seal *outcome) Run(ctx context.Context, rs *fst.RunState) error {
}
}
err := seal.sys.Revert(ec)
err := a.appSeal.sys.Revert(ec)
if err != nil {
err = err.(RevertCompoundError)
}
@ -133,9 +129,9 @@ func (seal *outcome) Run(ctx context.Context, rs *fst.RunState) error {
waitErr := make(chan error, 1)
cmd := new(shim.Shim)
if startTime, err := cmd.Start(
seal.user.aid.String(),
seal.user.supp,
seal.bwrapSync,
a.appSeal.user.aid.String(),
a.appSeal.user.supp,
a.appSeal.bwrapSync,
); err != nil {
return err
} else {
@ -143,20 +139,20 @@ func (seal *outcome) Run(ctx context.Context, rs *fst.RunState) error {
rs.Time = startTime
}
c, cancel := context.WithTimeout(ctx, shimSetupTimeout)
defer cancel()
shimSetupCtx, shimSetupCancel := context.WithDeadline(ctx, time.Now().Add(shimSetupTimeout))
defer shimSetupCancel()
go func() {
waitErr <- cmd.Unwrap().Wait()
// cancel shim setup in case shim died before receiving payload
cancel()
shimSetupCancel()
}()
if err := cmd.Serve(c, &shim.Payload{
Argv: seal.command,
if err := cmd.Serve(shimSetupCtx, &shim.Payload{
Argv: a.appSeal.command,
Exec: shimExec,
Bwrap: seal.container,
Home: seal.user.data,
Bwrap: a.appSeal.container,
Home: a.appSeal.user.data,
Verbose: fmsg.Load(),
}); err != nil {
@ -165,14 +161,14 @@ func (seal *outcome) Run(ctx context.Context, rs *fst.RunState) error {
// shim accepted setup payload, create process state
sd := state.State{
ID: seal.id.unwrap(),
ID: a.id.unwrap(),
PID: cmd.Unwrap().Process.Pid,
Time: *rs.Time,
}
var earlyStoreErr = new(StateStoreError) // returned after blocking on waitErr
earlyStoreErr.Inner, earlyStoreErr.DoErr = store.Do(seal.user.aid.unwrap(), func(c state.Cursor) { earlyStoreErr.InnerErr = c.Save(&sd, seal.ct) })
earlyStoreErr.Inner, earlyStoreErr.DoErr = store.Do(a.appSeal.user.aid.unwrap(), func(c state.Cursor) { earlyStoreErr.InnerErr = c.Save(&sd, a.appSeal.ct) })
// destroy defunct state entry
deferredStoreFunc = func(c state.Cursor) error { return c.Destroy(seal.id.unwrap()) }
deferredStoreFunc = func(c state.Cursor) error { return c.Destroy(a.id.unwrap()) }
select {
case err := <-waitErr: // block until fsu/shim returns
@ -205,9 +201,9 @@ func (seal *outcome) Run(ctx context.Context, rs *fst.RunState) error {
}
fmsg.Resume()
if seal.dbusMsg != nil {
if a.appSeal.dbusMsg != nil {
// dump dbus message buffer
seal.dbusMsg()
a.appSeal.dbusMsg()
}
return earlyStoreErr.equiv("cannot save process state:")

View File

@ -11,7 +11,6 @@ import (
"path"
"regexp"
"strings"
"sync/atomic"
"git.gensokyo.uk/security/fortify/acl"
"git.gensokyo.uk/security/fortify/dbus"
@ -58,13 +57,8 @@ var (
var posixUsername = regexp.MustCompilePOSIX("^[a-z_]([A-Za-z0-9_-]{0,31}|[A-Za-z0-9_-]{0,30}\\$)$")
// outcome stores copies of various parts of [fst.Config]
type outcome struct {
// copied from initialising [app]
id *stringPair[fst.ID]
// copied from [sys.State] response
runDirPath string
// appSeal stores copies of various parts of [fst.Config]
type appSeal struct {
// passed through from [fst.Config]
command []string
@ -74,16 +68,16 @@ type outcome struct {
// dump dbus proxy message buffer
dbusMsg func()
user fsuUser
user appUser
sys *system.I
container *bwrap.Config
bwrapSync *os.File
f atomic.Bool
// protected by upstream mutex
}
// fsuUser stores post-fsu credentials and metadata
type fsuUser struct {
// appUser stores post-fsu credentials and metadata
type appUser struct {
// application id
aid *stringPair[int]
// target uid resolved by fid:aid
@ -100,7 +94,7 @@ type fsuUser struct {
username string
}
func (seal *outcome) finalise(sys sys.State, config *fst.Config) error {
func (seal *appSeal) finalise(sys sys.State, config *fst.Config, id string) error {
{
// encode initial configuration for state tracking
ct := new(bytes.Buffer)
@ -124,7 +118,7 @@ func (seal *outcome) finalise(sys sys.State, config *fst.Config) error {
Resolve post-fsu user state
*/
seal.user = fsuUser{
seal.user = appUser{
aid: newInt(config.Confinement.AppID),
data: config.Confinement.Outer,
home: config.Confinement.Inner,
@ -229,7 +223,6 @@ func (seal *outcome) finalise(sys sys.State, config *fst.Config) error {
*/
sc := sys.Paths()
seal.runDirPath = sc.RunDirPath
seal.sys = system.New(seal.user.uid.unwrap())
seal.sys.IsVerbose = fmsg.Load
seal.sys.Verbose = fmsg.Verbose
@ -250,10 +243,10 @@ func (seal *outcome) finalise(sys sys.State, config *fst.Config) error {
seal.sys.UpdatePermType(system.User, sc.RuntimePath, acl.Execute)
// outer process-specific share directory
sharePath := path.Join(sc.SharePath, seal.id.String())
sharePath := path.Join(sc.SharePath, id)
seal.sys.Ephemeral(system.Process, sharePath, 0711)
// similar to share but within XDG_RUNTIME_DIR
sharePathLocal := path.Join(sc.RunDirPath, seal.id.String())
sharePathLocal := path.Join(sc.RunDirPath, id)
seal.sys.Ephemeral(system.Process, sharePathLocal, 0700)
seal.sys.UpdatePerm(sharePathLocal, acl.Execute)
@ -334,14 +327,14 @@ func (seal *outcome) finalise(sys sys.State, config *fst.Config) error {
if !config.Confinement.Sandbox.DirectWayland { // set up security-context-v1
socketDir := path.Join(sc.SharePath, "wayland")
outerPath := path.Join(socketDir, seal.id.String())
outerPath := path.Join(socketDir, id)
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()
appID = "uk.gensokyo.fortify." + id
}
seal.sys.Wayland(&seal.bwrapSync, outerPath, socketPath, appID, seal.id.String())
seal.sys.Wayland(&seal.bwrapSync, outerPath, socketPath, appID, id)
seal.container.Bind(outerPath, innerPath)
} else { // bind mount wayland socket (insecure)
fmsg.Verbose("direct wayland access, PROCEED WITH CAUTION")

425
main.go
View File

@ -3,19 +3,19 @@ package main
import (
"context"
_ "embed"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/signal"
"os/user"
"strconv"
"strings"
"sync"
"syscall"
"text/tabwriter"
"time"
"git.gensokyo.uk/security/fortify/command"
"git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/seccomp"
@ -30,16 +30,36 @@ import (
)
var (
errSuccess = errors.New("success")
flagVerbose bool
flagJSON bool
//go:embed LICENSE
license string
)
func init() { fmsg.Prepare("fortify") }
func init() {
fmsg.Prepare("fortify")
flag.BoolVar(&flagVerbose, "v", false, "Verbose output")
flag.BoolVar(&flagJSON, "json", false, "Format output in JSON when applicable")
}
var std sys.State = new(sys.Std)
type gl []string
func (g *gl) String() string {
if g == nil {
return "<nil>"
}
return strings.Join(*g, " ")
}
func (g *gl) Set(v string) error {
*g = append(*g, v)
return nil
}
func main() {
// early init argv0 check, skips root check and duplicate PR_SET_DUMPABLE
init0.TryArgv0()
@ -53,56 +73,112 @@ func main() {
log.Fatal("this program must not run as root")
}
err := buildCommand(os.Stderr).Parse(os.Args[1:])
if errors.Is(err, errSuccess) || errors.Is(err, command.ErrHelp) {
internal.Exit(0)
panic("unreachable")
}
if errors.Is(err, command.ErrNoMatch) || errors.Is(err, command.ErrEmptyTree) {
internal.Exit(1)
panic("unreachable")
}
if err == nil {
log.Fatal("unreachable")
flag.CommandLine.Usage = func() {
fmt.Println()
fmt.Println("Usage:\tfortify [-v] [--json] COMMAND [OPTIONS]")
fmt.Println()
fmt.Println("Commands:")
w := tabwriter.NewWriter(os.Stdout, 0, 1, 4, ' ', 0)
commands := [][2]string{
{"app", "Launch app defined by the specified config file"},
{"run", "Configure and start a permissive default sandbox"},
{"show", "Show the contents of an app configuration"},
{"ps", "List active apps and their state"},
{"version", "Show fortify version"},
{"license", "Show full license text"},
{"template", "Produce a config template"},
{"help", "Show this help message"},
}
for _, c := range commands {
_, _ = fmt.Fprintf(w, "\t%s\t%s\n", c[0], c[1])
}
if err := w.Flush(); err != nil {
fmt.Printf("fortify: cannot write command list: %v\n", err)
}
fmt.Println()
}
flag.Parse()
fmsg.Store(flagVerbose)
var flagError command.FlagError
if !errors.As(err, &flagError) {
log.Printf("command: %v", err)
internal.Exit(1)
panic("unreachable")
}
fmsg.Verbose(flagError.Error())
if flagError.Success() {
args := flag.Args()
if len(args) == 0 {
flag.CommandLine.Usage()
internal.Exit(0)
}
internal.Exit(1)
}
func buildCommand(out io.Writer) command.Command {
var (
flagVerbose bool
flagJSON bool
)
c := command.New(out, log.Printf, "fortify", func([]string) error { fmsg.Store(flagVerbose); return nil }).
Flag(&flagVerbose, "v", command.BoolFlag(false), "Print debug messages to the console").
Flag(&flagJSON, "json", command.BoolFlag(false), "Serialise output as JSON when applicable")
switch args[0] {
case "version": // print version string
if v, ok := internal.Check(internal.Version); ok {
fmt.Println(v)
} else {
fmt.Println("impure")
}
internal.Exit(0)
c.Command("app", "Launch app defined by the specified config file", func(args []string) error {
if len(args) < 1 {
case "license": // print embedded license
fmt.Println(license)
internal.Exit(0)
case "template": // print full template configuration
printJSON(os.Stdout, false, fst.Template())
internal.Exit(0)
case "help": // print help message
flag.CommandLine.Usage()
internal.Exit(0)
case "ps": // print all state info
set := flag.NewFlagSet("ps", flag.ExitOnError)
var short bool
set.BoolVar(&short, "short", false, "Print instance id")
// Ignore errors; set is set for ExitOnError.
_ = set.Parse(args[1:])
printPs(os.Stdout, time.Now().UTC(), state.NewMulti(std.Paths().RunDirPath), short)
internal.Exit(0)
case "show": // pretty-print app info
set := flag.NewFlagSet("show", flag.ExitOnError)
var short bool
set.BoolVar(&short, "short", false, "Omit filesystem information")
// Ignore errors; set is set for ExitOnError.
_ = set.Parse(args[1:])
switch len(set.Args()) {
case 0: // system
printShowSystem(os.Stdout, short)
case 1: // instance
name := set.Args()[0]
config, instance := tryShort(name)
if config == nil {
config = tryPath(name)
}
printShowInstance(os.Stdout, time.Now().UTC(), instance, config, short)
default:
log.Fatal("show requires 1 argument")
}
internal.Exit(0)
case "app": // launch app from configuration file
if len(args) < 2 {
log.Fatal("app requires at least 1 argument")
}
// config extraArgs...
config := tryPath(args[0])
config.Command = append(config.Command, args[1:]...)
config := tryPath(args[1])
config.Command = append(config.Command, args[2:]...)
// invoke app
runApp(app.MustNew(std), config)
runApp(config)
panic("unreachable")
})
{
case "run": // run app in permissive defaults usage pattern
set := flag.NewFlagSet("run", flag.ExitOnError)
var (
dbusConfigSession string
dbusConfigSystem string
@ -111,191 +187,138 @@ func buildCommand(out io.Writer) command.Command {
fid string
aid int
groups command.RepeatableFlag
groups gl
homeDir string
userName string
enablements [system.ELen]bool
)
c.NewCommand("run", "Configure and start a permissive default sandbox", func(args []string) error {
// initialise config from flags
config := &fst.Config{
ID: fid,
Command: args,
}
set.StringVar(&dbusConfigSession, "dbus-config", "builtin", "Path to D-Bus proxy config file, or \"builtin\" for defaults")
set.StringVar(&dbusConfigSystem, "dbus-system", "nil", "Path to system D-Bus proxy config file, or \"nil\" to disable")
set.BoolVar(&mpris, "mpris", false, "Allow owning MPRIS D-Bus path, has no effect if custom config is available")
set.BoolVar(&dbusVerbose, "dbus-log", false, "Force logging in the D-Bus proxy")
if aid < 0 || aid > 9999 {
log.Fatalf("aid %d out of range", aid)
}
set.StringVar(&fid, "id", "", "App ID, leave empty to disable security context app_id")
set.IntVar(&aid, "a", 0, "Fortify application ID")
set.Var(&groups, "g", "Groups inherited by the app process")
set.StringVar(&homeDir, "d", "os", "Application home directory")
set.StringVar(&userName, "u", "chronos", "Passwd name within sandbox")
set.BoolVar(&enablements[system.EWayland], "wayland", false, "Allow Wayland connections")
set.BoolVar(&enablements[system.EX11], "X", false, "Share X11 socket and allow connection")
set.BoolVar(&enablements[system.EDBus], "dbus", false, "Proxy D-Bus connection")
set.BoolVar(&enablements[system.EPulse], "pulse", false, "Share PulseAudio socket and cookie")
// resolve home/username from os when flag is unset
var (
passwd *user.User
passwdOnce sync.Once
passwdFunc = func() {
var us string
if uid, err := std.Uid(aid); err != nil {
fmsg.PrintBaseError(err, "cannot obtain uid from fsu:")
os.Exit(1)
} else {
us = strconv.Itoa(uid)
}
// Ignore errors; set is set for ExitOnError.
_ = set.Parse(args[1:])
if u, err := user.LookupId(us); err != nil {
fmsg.Verbosef("cannot look up uid %s", us)
passwd = &user.User{
Uid: us,
Gid: us,
Username: "chronos",
Name: "Fortify",
HomeDir: "/var/empty",
}
} else {
passwd = u
}
}
)
// initialise config from flags
config := &fst.Config{
ID: fid,
Command: set.Args(),
}
if homeDir == "os" {
passwdOnce.Do(passwdFunc)
homeDir = passwd.HomeDir
}
if aid < 0 || aid > 9999 {
log.Fatalf("aid %d out of range", aid)
}
if userName == "chronos" {
passwdOnce.Do(passwdFunc)
userName = passwd.Username
}
config.Confinement.AppID = aid
config.Confinement.Groups = groups
config.Confinement.Outer = homeDir
config.Confinement.Username = userName
// enablements from flags
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if enablements[i] {
config.Confinement.Enablements.Set(i)
}
}
// parse D-Bus config file from flags if applicable
if enablements[system.EDBus] {
if dbusConfigSession == "builtin" {
config.Confinement.SessionBus = dbus.NewConfig(fid, true, mpris)
// resolve home/username from os when flag is unset
var (
passwd *user.User
passwdOnce sync.Once
passwdFunc = func() {
var us string
if uid, err := std.Uid(aid); err != nil {
fmsg.PrintBaseError(err, "cannot obtain uid from fsu:")
os.Exit(1)
} else {
if conf, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil {
log.Fatalf("cannot load session bus proxy config from %q: %s", dbusConfigSession, err)
} else {
config.Confinement.SessionBus = conf
}
us = strconv.Itoa(uid)
}
// system bus proxy is optional
if dbusConfigSystem != "nil" {
if conf, err := dbus.NewConfigFromFile(dbusConfigSystem); err != nil {
log.Fatalf("cannot load system bus proxy config from %q: %s", dbusConfigSystem, err)
} else {
config.Confinement.SystemBus = conf
if u, err := user.LookupId(us); err != nil {
fmsg.Verbosef("cannot look up uid %s", us)
passwd = &user.User{
Uid: us,
Gid: us,
Username: "chronos",
Name: "Fortify",
HomeDir: "/var/empty",
}
} else {
passwd = u
}
}
)
// override log from configuration
if dbusVerbose {
config.Confinement.SessionBus.Log = true
config.Confinement.SystemBus.Log = true
if homeDir == "os" {
passwdOnce.Do(passwdFunc)
homeDir = passwd.HomeDir
}
if userName == "chronos" {
passwdOnce.Do(passwdFunc)
userName = passwd.Username
}
config.Confinement.AppID = aid
config.Confinement.Groups = groups
config.Confinement.Outer = homeDir
config.Confinement.Username = userName
// enablements from flags
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if enablements[i] {
config.Confinement.Enablements.Set(i)
}
}
// parse D-Bus config file from flags if applicable
if enablements[system.EDBus] {
if dbusConfigSession == "builtin" {
config.Confinement.SessionBus = dbus.NewConfig(fid, true, mpris)
} else {
if c, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil {
log.Fatalf("cannot load session bus proxy config from %q: %s", dbusConfigSession, err)
} else {
config.Confinement.SessionBus = c
}
}
// invoke app
runApp(app.MustNew(std), config)
panic("unreachable")
}).
Flag(&dbusConfigSession, "dbus-config", command.StringFlag("builtin"),
"Path to D-Bus proxy config file, or \"builtin\" for defaults").
Flag(&dbusConfigSystem, "dbus-system", command.StringFlag("nil"),
"Path to system D-Bus proxy config file, or \"nil\" to disable").
Flag(&mpris, "mpris", command.BoolFlag(false),
"Allow owning MPRIS D-Bus path, has no effect if custom config is available").
Flag(&dbusVerbose, "dbus-log", command.BoolFlag(false),
"Force logging in the D-Bus proxy").
Flag(&fid, "id", command.StringFlag(""),
"App ID, leave empty to disable security context app_id").
Flag(&aid, "a", command.IntFlag(0),
"Fortify application ID").
Flag(nil, "g", &groups,
"Groups inherited by the app process").
Flag(&homeDir, "d", command.StringFlag("os"),
"Application home directory").
Flag(&userName, "u", command.StringFlag("chronos"),
"Passwd name within sandbox").
Flag(&enablements[system.EWayland], "wayland", command.BoolFlag(false),
"Allow Wayland connections").
Flag(&enablements[system.EX11], "X", command.BoolFlag(false),
"Share X11 socket and allow connection").
Flag(&enablements[system.EDBus], "dbus", command.BoolFlag(false),
"Proxy D-Bus connection").
Flag(&enablements[system.EPulse], "pulse", command.BoolFlag(false),
"Share PulseAudio socket and cookie")
}
var showFlagShort bool
c.NewCommand("show", "Show the contents of an app configuration", func(args []string) error {
switch len(args) {
case 0: // system
printShowSystem(os.Stdout, showFlagShort, flagJSON)
case 1: // instance
name := args[0]
config, instance := tryShort(name)
if config == nil {
config = tryPath(name)
// system bus proxy is optional
if dbusConfigSystem != "nil" {
if c, err := dbus.NewConfigFromFile(dbusConfigSystem); err != nil {
log.Fatalf("cannot load system bus proxy config from %q: %s", dbusConfigSystem, err)
} else {
config.Confinement.SystemBus = c
}
}
printShowInstance(os.Stdout, time.Now().UTC(), instance, config, showFlagShort, flagJSON)
default:
log.Fatal("show requires 1 argument")
// override log from configuration
if dbusVerbose {
config.Confinement.SessionBus.Log = true
config.Confinement.SystemBus.Log = true
}
}
return errSuccess
}).Flag(&showFlagShort, "short", command.BoolFlag(false), "Omit filesystem information")
var psFlagShort bool
c.NewCommand("ps", "List active apps and their state", func(args []string) error {
printPs(os.Stdout, time.Now().UTC(), state.NewMulti(std.Paths().RunDirPath), psFlagShort, flagJSON)
return errSuccess
}).Flag(&psFlagShort, "short", command.BoolFlag(false), "Print instance id")
c.Command("version", "Show fortify version", func(args []string) error {
if v, ok := internal.Check(internal.Version); ok {
fmt.Println(v)
} else {
fmt.Println("impure")
}
return errSuccess
})
c.Command("license", "Show full license text", func(args []string) error {
fmt.Println(license)
return errSuccess
})
c.Command("template", "Produce a config template", func(args []string) error {
printJSON(os.Stdout, false, fst.Template())
return errSuccess
})
c.Command("help", "Show this help message", func([]string) error {
c.PrintHelp()
return errSuccess
})
// invoke app
runApp(config)
panic("unreachable")
// internal commands
c.Command("shim", command.UsageInternal, func([]string) error { shim.Main(); return errSuccess })
c.Command("init", command.UsageInternal, func([]string) error { init0.Main(); return errSuccess })
case "shim":
shim.Main()
internal.Exit(0)
case "init":
init0.Main()
internal.Exit(0)
return c
default:
log.Fatalf("%q is not a valid command", args[0])
}
panic("unreachable")
}
func runApp(a fst.App, config *fst.Config) {
func runApp(config *fst.Config) {
rs := new(fst.RunState)
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
@ -305,10 +328,12 @@ func runApp(a fst.App, config *fst.Config) {
seccomp.CPrintln = log.Println
}
if sa, err := a.Seal(config); err != nil {
if a, err := app.New(std); err != nil {
log.Fatalf("cannot create app: %s", err)
} else if err = a.Seal(config); err != nil {
fmsg.PrintBaseError(err, "cannot seal app:")
internal.Exit(1)
} else if err = sa.Run(ctx, rs); err != nil {
} else if err = a.Run(ctx, rs); err != nil {
if rs.Time == nil {
fmsg.PrintBaseError(err, "cannot start app:")
} else {

View File

@ -1,81 +0,0 @@
package main
import (
"bytes"
"errors"
"flag"
"testing"
"git.gensokyo.uk/security/fortify/command"
)
func TestHelp(t *testing.T) {
testCases := []struct {
name string
args []string
want string
}{
{
"main", []string{}, `
Usage: fortify [-h | --help] [-v] [--json] COMMAND [OPTIONS]
Commands:
app Launch app defined by the specified config file
run Configure and start a permissive default sandbox
show Show the contents of an app configuration
ps List active apps and their state
version Show fortify version
license Show full license text
template Produce a config template
help Show this help message
`,
},
{
"run", []string{"run", "-h"}, `
Usage: fortify run [-h | --help] [--dbus-config <value>] [--dbus-system <value>] [--mpris] [--dbus-log] [--id <value>] [-a <int>] [-g <value>] [-d <value>] [-u <value>] [--wayland] [-X] [--dbus] [--pulse] COMMAND [OPTIONS]
Flags:
-X Share X11 socket and allow connection
-a int
Fortify application ID
-d string
Application home directory (default "os")
-dbus
Proxy D-Bus connection
-dbus-config string
Path to D-Bus proxy config file, or "builtin" for defaults (default "builtin")
-dbus-log
Force logging in the D-Bus proxy
-dbus-system string
Path to system D-Bus proxy config file, or "nil" to disable (default "nil")
-g value
Groups inherited by the app process
-id string
App ID, leave empty to disable security context app_id
-mpris
Allow owning MPRIS D-Bus path, has no effect if custom config is available
-pulse
Share PulseAudio socket and cookie
-u string
Passwd name within sandbox (default "chronos")
-wayland
Allow Wayland connections
`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
out := new(bytes.Buffer)
c := buildCommand(out)
if err := c.Parse(tc.args); !errors.Is(err, command.ErrHelp) && !errors.Is(err, flag.ErrHelp) {
t.Errorf("Parse: error = %v; want %v",
err, command.ErrHelp)
}
if got := out.String(); got != tc.want {
t.Errorf("Parse: %s want %s", got, tc.want)
}
})
}
}

View File

@ -36,7 +36,7 @@ package
*Default:*
` <derivation fortify-0.2.17> `
` <derivation fortify-0.2.16> `

View File

@ -16,7 +16,7 @@
buildGoModule rec {
pname = "fortify";
version = "0.2.17";
version = "0.2.16";
src = builtins.path {
name = "fortify-src";

View File

@ -18,7 +18,7 @@ import (
"git.gensokyo.uk/security/fortify/internal/state"
)
func printShowSystem(output io.Writer, short, flagJSON bool) {
func printShowSystem(output io.Writer, short bool) {
t := newPrinter(output)
defer t.MustFlush()
@ -43,7 +43,7 @@ func printShowSystem(output io.Writer, short, flagJSON bool) {
func printShowInstance(
output io.Writer, now time.Time,
instance *state.State, config *fst.Config,
short, flagJSON bool) {
short bool) {
if flagJSON {
if instance != nil {
printJSON(output, short, instance)
@ -190,7 +190,7 @@ func printShowInstance(
}
}
func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON bool) {
func printPs(output io.Writer, now time.Time, s state.Store, short bool) {
var entries state.Entries
if e, err := state.Join(s); err != nil {
log.Fatalf("cannot join store: %v", err)

View File

@ -448,8 +448,14 @@ App
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
{
v := flagJSON
t.Cleanup(func() { flagJSON = v })
flagJSON = tc.json
}
output := new(strings.Builder)
printShowInstance(output, testTime, tc.instance, tc.config, tc.short, tc.json)
printShowInstance(output, testTime, tc.instance, tc.config, tc.short)
if got := output.String(); got != tc.want {
t.Errorf("printShowInstance: got\n%s\nwant\n%s",
got, tc.want)
@ -639,8 +645,14 @@ func Test_printPs(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
{
v := flagJSON
t.Cleanup(func() { flagJSON = v })
flagJSON = tc.json
}
output := new(strings.Builder)
printPs(output, testTime, stubStore(tc.entries), tc.short, tc.json)
printPs(output, testTime, stubStore(tc.entries), tc.short)
if got := output.String(); got != tc.want {
t.Errorf("printPs: got\n%s\nwant\n%s",
got, tc.want)

View File

@ -1,4 +1,3 @@
// Package system provides tools for safely interacting with the operating system.
package system
import (
@ -15,7 +14,6 @@ const (
Process = Enablement(ELen + 1)
)
// Criteria specifies types of Op to revert.
type Criteria struct {
*Enablements
}
@ -44,7 +42,6 @@ type Op interface {
String() string
}
// TypeString returns the string representation of a type stored as an [Enablement].
func TypeString(e Enablement) string {
switch e {
case User:
@ -67,7 +64,6 @@ func New(uid int) (sys *I) {
return
}
// An I provides indirect bulk operating system interaction. I must not be copied.
type I struct {
uid int
ops []Op
@ -95,7 +91,6 @@ func (sys *I) wrapErrSuffix(err error, a ...any) error {
return sys.wrapErr(err, append(a, err)...)
}
// Equal returns whether all [Op] instances held by v is identical to that of sys.
func (sys *I) Equal(v *I) bool {
if v == nil || sys.uid != v.uid || len(sys.ops) != len(v.ops) {
return false
@ -110,8 +105,6 @@ func (sys *I) Equal(v *I) bool {
return true
}
// Commit applies all [Op] held by [I] and reverts successful [Op] on first error encountered.
// Commit must not be called more than once.
func (sys *I) Commit(ctx context.Context) error {
sys.lock.Lock()
defer sys.lock.Unlock()
@ -148,7 +141,6 @@ func (sys *I) Commit(ctx context.Context) error {
return nil
}
// Revert reverts all [Op] meeting [Criteria] held by [I].
func (sys *I) Revert(ec *Criteria) error {
sys.lock.Lock()
defer sys.lock.Unlock()

401
test.nix Normal file
View File

@ -0,0 +1,401 @@
{
system,
self,
home-manager,
nixosTest,
fortify,
}:
nixosTest {
name = "fortify";
# 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;
nodes.machine =
{
lib,
pkgs,
config,
...
}:
{
users.users = {
alice = {
isNormalUser = true;
description = "Alice Foobar";
password = "foobar";
uid = 1000;
};
untrusted = {
isNormalUser = true;
description = "Untrusted user";
password = "foobar";
uid = 1001;
# For deny unmapped uid test:
packages = [ config.environment.fortify.package ];
};
};
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 glinfo and wayland-info:
mesa-demos
wayland-utils
# For D-Bus tests:
libnotify
mako
# For go tests:
self.packages.${system}.fhs
];
variables = {
SWAYSOCK = "/tmp/sway-ipc.sock";
WLR_RENDERER = "pixman";
};
# To help with OCR:
etc."xdg/foot/foot.ini".text = lib.generators.toINI { } {
main = {
font = "inconsolata:size=14";
};
colors = rec {
foreground = "000000";
background = "ffffff";
regular2 = foreground;
};
};
};
fonts.packages = [ pkgs.inconsolata ];
# 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=sway sway && touch /tmp/sway-exit-ok
fi
'';
programs.sway.enable = true;
# For PulseAudio tests:
security.rtkit.enable = true;
services.pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
jack.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 Go test compiler performance:
"-smp 8"
];
environment.fortify = {
enable = true;
package = fortify.overrideAttrs (previousAttrs: {
GOFLAGS = previousAttrs.GOFLAGS ++ [ "-race" ];
# fsu does not like cgo
disallowedReferences = previousAttrs.disallowedReferences ++ [ fortify ];
postInstall =
previousAttrs.postInstall
+ ''
cp -a "${fortify}/libexec/fsu" "$out/libexec/fsu"
sed -i 's:${fortify}:${placeholder "out"}:' "$out/libexec/fsu"
'';
});
stateDir = "/var/lib/fortify";
users.alice = 0;
home-manager = _: _: { home.stateVersion = "23.05"; };
apps = [
{
name = "ne-foot";
verbose = true;
share = pkgs.foot;
packages = [ pkgs.foot ];
command = "foot";
capability = {
dbus = false;
pulse = false;
};
}
{
name = "pa-foot";
verbose = true;
share = pkgs.foot;
packages = [ pkgs.foot ];
command = "foot";
capability.dbus = false;
}
{
name = "x11-alacritty";
verbose = true;
share = pkgs.alacritty;
packages = [ pkgs.alacritty ];
command = "alacritty";
capability = {
wayland = false;
x11 = true;
dbus = false;
pulse = false;
};
}
{
name = "da-foot";
verbose = true;
insecureWayland = true;
share = pkgs.foot;
packages = [ pkgs.foot ];
command = "foot";
capability = {
dbus = false;
pulse = false;
};
}
{
name = "strace-failure";
verbose = true;
share = pkgs.strace;
command = "strace true";
capability = {
wayland = false;
x11 = false;
dbus = false;
pulse = false;
};
}
];
};
imports = [
self.nixosModules.fortify
home-manager.nixosModules.home-manager
];
};
testScript = ''
import shlex
import json
q = shlex.quote
NODE_GROUPS = ["nodes", "floating_nodes"]
def swaymsg(command: str = "", succeed=True, type="command"):
assert command != "" or type != "command", "Must specify command or type"
shell = q(f"swaymsg -t {q(type)} -- {q(command)}")
with machine.nested(
f"sending swaymsg {shell!r}" + " (allowed to fail)" * (not succeed)
):
ret = (machine.succeed if succeed else machine.execute)(
f"su - alice -c {shell}"
)
# execute also returns a status code, but disregard.
if not succeed:
_, ret = ret
if not succeed and not ret:
return None
parsed = json.loads(ret)
return parsed
def walk(tree):
yield tree
for group in NODE_GROUPS:
for node in tree.get(group, []):
yield from walk(node)
def wait_for_window(pattern):
def func(last_chance):
nodes = (node["name"] for node in walk(swaymsg(type="get_tree")))
if last_chance:
nodes = list(nodes)
machine.log(f"Last call! Current list of windows: {nodes}")
return any(pattern in name for name in nodes)
retry(func)
def collect_state_ui(name):
swaymsg(f"exec fortify ps > '/tmp/{name}.ps'")
machine.copy_from_vm(f"/tmp/{name}.ps", "")
swaymsg(f"exec fortify --json ps > '/tmp/{name}.json'")
machine.copy_from_vm(f"/tmp/{name}.json", "")
machine.screenshot(name)
def check_state(name, enablements):
instances = json.loads(machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 fortify --json ps"))
if len(instances) != 1:
raise Exception(f"unexpected state length {len(instances)}")
instance = next(iter(instances.values()))
config = instance['config']
if len(config['command']) != 1 or not(config['command'][0].startswith("/nix/store/")) or not(config['command'][0].endswith(f"{name}-start")):
raise Exception(f"unexpected command {instance['config']['command']}")
if config['confinement']['enablements'] != enablements:
raise Exception(f"unexpected enablements {instance['config']['confinement']['enablements']}")
def fortify(command):
swaymsg(f"exec fortify {command}")
start_all()
machine.wait_for_unit("multi-user.target")
# Run fortify Go tests outside of nix build in the background:
machine.succeed("rm -rf /tmp/src && cp -a '${self.packages.${system}.fortify.src}' /tmp/src")
machine.succeed("fortify-fhs -c '(cd /tmp/src && go generate ./... && go test ./... && touch /tmp/success-gotest)' &> /tmp/gotest &")
# 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")
# Deny unmapped uid:
denyOutput = machine.fail("sudo -u untrusted -i fortify run &>/dev/stdout")
print(denyOutput)
denyOutputVerbose = machine.fail("sudo -u untrusted -i fortify -v run &>/dev/stdout")
print(denyOutputVerbose)
# Verify PrintBaseError behaviour:
if denyOutput != "fsu: uid 1001 is not in the fsurc file\n":
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":
raise Exception(f"unexpected deny verbose output:\n{denyOutputVerbose}")
# Start fortify permissive defaults outside Wayland session:
print(machine.succeed("sudo -u alice -i fortify -v run -a 0 touch /tmp/success-bare"))
machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-bare")
# Verify silent output permissive defaults:
output = machine.succeed("sudo -u alice -i fortify run -a 0 true &>/dev/stdout")
if output != "":
raise Exception(f"unexpected output\n{output}")
# Verify graceful failure on bad Wayland display name:
print(machine.fail("sudo -u alice -i fortify -v run --wayland true"))
# Start fortify permissive defaults within Wayland session:
fortify('-v run --wayland --dbus notify-send -a "NixOS Tests" "Test notification" "Notification from within sandbox." && touch /tmp/dbus-done')
machine.wait_for_file("/tmp/dbus-done")
collect_state_ui("dbus_notify_exited")
machine.succeed("pkill -9 mako")
# Start app (foot) with Wayland enablement:
swaymsg("exec ne-foot")
wait_for_window("u0_a1@machine")
machine.send_chars("clear; wayland-info && touch /tmp/success-client\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/1/success-client")
collect_state_ui("foot_wayland")
check_state("ne-foot", 1)
# Verify acl on XDG_RUNTIME_DIR:
print(machine.succeed("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000001"))
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep foot")
# Verify acl cleanup on XDG_RUNTIME_DIR:
machine.wait_until_fails("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000001")
# 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("u0_a1@machine")
machine.send_chars("clear; wayland-info && touch /tmp/success-client-term\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/1/success-client-term")
machine.wait_for_file("/tmp/ps-show-ok")
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")
# Test PulseAudio (fortify does not support PipeWire yet):
swaymsg("exec pa-foot")
wait_for_window("u0_a2@machine")
machine.send_chars("clear; pactl info && touch /tmp/success-pulse\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/2/success-pulse")
collect_state_ui("pulse_wayland")
check_state("pa-foot", 9)
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep foot")
# Test XWayland (foot does not support X):
swaymsg("exec x11-alacritty")
wait_for_window("u0_a3@machine")
machine.send_chars("clear; glinfo && touch /tmp/success-client-x11\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/3/success-client-x11")
collect_state_ui("alacritty_x11")
check_state("x11-alacritty", 2)
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep alacritty")
# Start app (foot) with direct Wayland access:
swaymsg("exec da-foot")
wait_for_window("u0_a4@machine")
machine.send_chars("clear; wayland-info && touch /tmp/success-direct\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/4/success-direct")
collect_state_ui("foot_direct")
check_state("da-foot", 1)
# Verify acl on XDG_RUNTIME_DIR:
print(machine.succeed("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000004"))
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep foot")
# Verify acl cleanup on XDG_RUNTIME_DIR:
machine.wait_until_fails("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000004")
# Test syscall filter:
print(machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 strace-failure"))
# Exit Sway and verify process exit status 0:
swaymsg("exit", succeed=False)
machine.wait_until_fails("pgrep -x sway")
machine.wait_for_file("/tmp/sway-exit-ok")
# Print fortify runDir contents:
print(machine.succeed("find /run/user/1000/fortify"))
# Verify go test status:
machine.wait_for_file("/tmp/gotest")
print(machine.succeed("cat /tmp/gotest"))
machine.wait_for_file("/tmp/success-gotest")
'';
}

View File

@ -1,163 +0,0 @@
{
lib,
pkgs,
config,
...
}:
{
users.users = {
alice = {
isNormalUser = true;
description = "Alice Foobar";
password = "foobar";
uid = 1000;
};
untrusted = {
isNormalUser = true;
description = "Untrusted user";
password = "foobar";
uid = 1001;
# For deny unmapped uid test:
packages = [ config.environment.fortify.package ];
};
};
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 glinfo and wayland-info:
mesa-demos
wayland-utils
# For D-Bus tests:
libnotify
mako
];
variables = {
SWAYSOCK = "/tmp/sway-ipc.sock";
WLR_RENDERER = "pixman";
};
# To help with OCR:
etc."xdg/foot/foot.ini".text = lib.generators.toINI { } {
main = {
font = "inconsolata:size=14";
};
colors = rec {
foreground = "000000";
background = "ffffff";
regular2 = foreground;
};
};
};
fonts.packages = [ pkgs.inconsolata ];
# 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=sway sway && touch /tmp/sway-exit-ok
fi
'';
programs.sway.enable = true;
# For PulseAudio tests:
security.rtkit.enable = true;
services.pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
jack.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 Go test compiler performance:
"-smp 8"
];
environment.fortify = {
enable = true;
stateDir = "/var/lib/fortify";
users.alice = 0;
home-manager = _: _: { home.stateVersion = "23.05"; };
apps = [
{
name = "ne-foot";
verbose = true;
share = pkgs.foot;
packages = [ pkgs.foot ];
command = "foot";
capability = {
dbus = false;
pulse = false;
};
}
{
name = "pa-foot";
verbose = true;
share = pkgs.foot;
packages = [ pkgs.foot ];
command = "foot";
capability.dbus = false;
}
{
name = "x11-alacritty";
verbose = true;
share = pkgs.alacritty;
packages = [ pkgs.alacritty ];
command = "alacritty";
capability = {
wayland = false;
x11 = true;
dbus = false;
pulse = false;
};
}
{
name = "da-foot";
verbose = true;
insecureWayland = true;
share = pkgs.foot;
packages = [ pkgs.foot ];
command = "foot";
capability = {
dbus = false;
pulse = false;
};
}
{
name = "strace-failure";
verbose = true;
share = pkgs.strace;
command = "strace true";
capability = {
wayland = false;
x11 = false;
dbus = false;
pulse = false;
};
}
];
};
}

View File

@ -1,51 +0,0 @@
{
system,
self,
nixosTest,
writeShellScriptBin,
}:
nixosTest {
name = "fortify";
nodes.machine = {
environment.systemPackages = [
# For go tests:
self.packages.${system}.fhs
(writeShellScriptBin "fortify-src" "echo -n ${self.packages.${system}.fortify.src}")
];
# Run with Go race detector:
environment.fortify.package =
let
inherit (self.packages.${system}) fortify;
in
fortify.overrideAttrs (previousAttrs: {
GOFLAGS = previousAttrs.GOFLAGS ++ [ "-race" ];
# fsu does not like cgo
disallowedReferences = previousAttrs.disallowedReferences ++ [ fortify ];
postInstall =
previousAttrs.postInstall
+ ''
cp -a "${fortify}/libexec/fsu" "$out/libexec/fsu"
sed -i 's:${fortify}:${placeholder "out"}:' "$out/libexec/fsu"
'';
});
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,199 +0,0 @@
import json
import shlex
q = shlex.quote
NODE_GROUPS = ["nodes", "floating_nodes"]
def swaymsg(command: str = "", succeed=True, type="command"):
assert command != "" or type != "command", "Must specify command or type"
shell = q(f"swaymsg -t {q(type)} -- {q(command)}")
with machine.nested(
f"sending swaymsg {shell!r}" + " (allowed to fail)" * (not succeed)
):
ret = (machine.succeed if succeed else machine.execute)(
f"su - alice -c {shell}"
)
# execute also returns a status code, but disregard.
if not succeed:
_, ret = ret
if not succeed and not ret:
return None
parsed = json.loads(ret)
return parsed
def walk(tree):
yield tree
for group in NODE_GROUPS:
for node in tree.get(group, []):
yield from walk(node)
def wait_for_window(pattern):
def func(last_chance):
nodes = (node["name"] for node in walk(swaymsg(type="get_tree")))
if last_chance:
nodes = list(nodes)
machine.log(f"Last call! Current list of windows: {nodes}")
return any(pattern in name for name in nodes)
retry(func)
def collect_state_ui(name):
swaymsg(f"exec fortify ps > '/tmp/{name}.ps'")
machine.copy_from_vm(f"/tmp/{name}.ps", "")
swaymsg(f"exec fortify --json ps > '/tmp/{name}.json'")
machine.copy_from_vm(f"/tmp/{name}.json", "")
machine.screenshot(name)
def check_state(name, enablements):
instances = json.loads(machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 fortify --json ps"))
if len(instances) != 1:
raise Exception(f"unexpected state length {len(instances)}")
instance = next(iter(instances.values()))
config = instance['config']
if len(config['command']) != 1 or not (config['command'][0].startswith("/nix/store/")) or not (
config['command'][0].endswith(f"{name}-start")):
raise Exception(f"unexpected command {instance['config']['command']}")
if config['confinement']['enablements'] != enablements:
raise Exception(f"unexpected enablements {instance['config']['confinement']['enablements']}")
def fortify(command):
swaymsg(f"exec fortify {command}")
start_all()
machine.wait_for_unit("multi-user.target")
# Run fortify Go tests outside of nix build in the background:
machine.succeed("rm -rf /tmp/src && cp -a \"$(fortify-src)\" /tmp/src")
machine.succeed(
"fortify-fhs -c '(cd /tmp/src && go generate ./... && go test ./... && touch /tmp/success-gotest)' &> /tmp/gotest &")
# 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")
# Deny unmapped uid:
denyOutput = machine.fail("sudo -u untrusted -i fortify run &>/dev/stdout")
print(denyOutput)
denyOutputVerbose = machine.fail("sudo -u untrusted -i fortify -v run &>/dev/stdout")
print(denyOutputVerbose)
# Verify PrintBaseError behaviour:
if denyOutput != "fsu: uid 1001 is not in the fsurc file\n":
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":
raise Exception(f"unexpected deny verbose output:\n{denyOutputVerbose}")
# Start fortify permissive defaults outside Wayland session:
print(machine.succeed("sudo -u alice -i fortify -v run -a 0 touch /tmp/success-bare"))
machine.wait_for_file("/tmp/fortify.1000/tmpdir/0/success-bare")
# Verify silent output permissive defaults:
output = machine.succeed("sudo -u alice -i fortify run -a 0 true &>/dev/stdout")
if output != "":
raise Exception(f"unexpected output\n{output}")
# Verify graceful failure on bad Wayland display name:
print(machine.fail("sudo -u alice -i fortify -v run --wayland true"))
# Start fortify permissive defaults within Wayland session:
fortify(
'-v run --wayland --dbus notify-send -a "NixOS Tests" "Test notification" "Notification from within sandbox." && touch /tmp/dbus-done')
machine.wait_for_file("/tmp/dbus-done")
collect_state_ui("dbus_notify_exited")
machine.succeed("pkill -9 mako")
# Start app (foot) with Wayland enablement:
swaymsg("exec ne-foot")
wait_for_window("u0_a1@machine")
machine.send_chars("clear; wayland-info && touch /tmp/success-client\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/1/success-client")
collect_state_ui("foot_wayland")
check_state("ne-foot", 1)
# Verify acl on XDG_RUNTIME_DIR:
print(machine.succeed("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000001"))
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep foot")
# Verify acl cleanup on XDG_RUNTIME_DIR:
machine.wait_until_fails("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000001")
# 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("u0_a1@machine")
machine.send_chars("clear; wayland-info && touch /tmp/success-client-term\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/1/success-client-term")
machine.wait_for_file("/tmp/ps-show-ok")
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")
# Test PulseAudio (fortify does not support PipeWire yet):
swaymsg("exec pa-foot")
wait_for_window("u0_a2@machine")
machine.send_chars("clear; pactl info && touch /tmp/success-pulse\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/2/success-pulse")
collect_state_ui("pulse_wayland")
check_state("pa-foot", 9)
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep foot")
# Test XWayland (foot does not support X):
swaymsg("exec x11-alacritty")
wait_for_window("u0_a3@machine")
machine.send_chars("clear; glinfo && touch /tmp/success-client-x11\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/3/success-client-x11")
collect_state_ui("alacritty_x11")
check_state("x11-alacritty", 2)
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep alacritty")
# Start app (foot) with direct Wayland access:
swaymsg("exec da-foot")
wait_for_window("u0_a4@machine")
machine.send_chars("clear; wayland-info && touch /tmp/success-direct\n")
machine.wait_for_file("/tmp/fortify.1000/tmpdir/4/success-direct")
collect_state_ui("foot_direct")
check_state("da-foot", 1)
# Verify acl on XDG_RUNTIME_DIR:
print(machine.succeed("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000004"))
machine.send_chars("exit\n")
machine.wait_until_fails("pgrep foot")
# Verify acl cleanup on XDG_RUNTIME_DIR:
machine.wait_until_fails("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000004")
# Test syscall filter:
print(machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 strace-failure"))
# Exit Sway and verify process exit status 0:
swaymsg("exit", succeed=False)
machine.wait_until_fails("pgrep -x sway")
machine.wait_for_file("/tmp/sway-exit-ok")
# Print fortify runDir contents:
print(machine.succeed("find /run/user/1000/fortify"))
# Verify go test status:
machine.wait_for_file("/tmp/gotest")
print(machine.succeed("cat /tmp/gotest"))
machine.wait_for_file("/tmp/success-gotest")