landdown

Simple Sandboxing for shell scripts.

git clone git://mccd.space/landdown

commit 6288e3a538b0fe79984b37832c3e6a94217e2d97
Author: Marc <marc@coquand.email>
Date:   Tue, 31 Mar 2026 12:50:50 +0200

Initial commit

Diffstat:
AREADME.md | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ago.mod | 9+++++++++
Ago.sum | 6++++++
Amain.go | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 223 insertions(+), 0 deletions(-)
diff --git a/README.md b/README.md
@@ -0,0 +1,61 @@
+# Landdown - Easy shell script sandbox
+
+A minimal Linux utility tool for locking down a shell script's access using [landlock](https://landlock.io).
+
+The aim is to allow developers to easily lock down scripts. I personally use it for my CGI scripts to have per-endpoint sandboxing.
+
+## Supported directives
+
+```
+- rof <file>
+- rwf <file>
+- ro <dir>
+- rw <dir>
+- bind <port>
+- connect <port>
+```
+
+## Examples
+
+### Hello world
+
+```sh
+#!/usr/local/bin/landdown
+ro /bin 
+ro /lib 
+#!/bin/sh
+echo "Hello world"
+```
+
+Try removing `/bin` or `/lib` and it should fail.
+
+### Edit a file
+
+```sh
+#!/usr/local/bin/landdown
+ro /bin 
+ro /lib
+rwf /tmp/some-file.txt
+#!/bin/sh
+echo Edit > /tmp/some-file.txt
+```
+
+Try removing `rwf /tmp/some-file.txt` and the script should fail.
+
+Note: the file need to exist in order to landlock it.
+
+### Curl google
+
+```sh
+#!/usr/local/bin/landdown
+ro /bin 
+ro /lib 
+ro /etc/ssl
+rof /etc/resolv.conf
+connect 443
+#!/bin/sh
+curl https://www.google.com
+```
+
+Try removing `ro /etc/ssl`, `rof /etc/resolv.conf`, or `connect 443` and it should fail.
+
diff --git a/go.mod b/go.mod
@@ -0,0 +1,9 @@
+module landdown
+
+go 1.26.1
+
+require (
+	github.com/landlock-lsm/go-landlock v0.7.0 // indirect
+	golang.org/x/sys v0.40.0 // indirect
+	kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect
+)
diff --git a/go.sum b/go.sum
@@ -0,0 +1,6 @@
+github.com/landlock-lsm/go-landlock v0.7.0 h1:gXz0+Phg3vddZjpPzXL4pQy/MgsTMHZBs+9zgUIyu/0=
+github.com/landlock-lsm/go-landlock v0.7.0/go.mod h1:mn5GSi81Jf7yMs5WSi+SUi4sUeNLUGVdbT4Id6wXNQw=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA=
+kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24=
diff --git a/main.go b/main.go
@@ -0,0 +1,147 @@
+package main
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"log"
+	"os"
+	"os/exec"
+	"strconv"
+	"strings"
+	"syscall"
+
+	"github.com/landlock-lsm/go-landlock/landlock"
+)
+
+func main() {
+	if len(os.Args) < 2 {
+		fmt.Fprintf(os.Stderr, "Usage: landdown <script> [args...]\n")
+		os.Exit(1)
+	}
+
+	scriptPath := os.Args[1]
+	extraArgs := os.Args[2:]
+
+	data, err := os.ReadFile(scriptPath)
+	if err != nil {
+		log.Fatalf("failed to read script: %v", err)
+	}
+
+	var roPaths, roFilePaths, rwFilePaths, rwPaths []string
+	var netRules []landlock.Rule = []landlock.Rule{}
+	var execCmd []string
+	var stdinData []byte
+
+	scanner := bufio.NewScanner(bytes.NewReader(data))
+	lineNum := 0
+	for scanner.Scan() {
+		lineNum++
+		line := strings.TrimSpace(scanner.Text())
+
+		// Skip the first shebang
+		if lineNum == 1 && strings.HasPrefix(line, "#!") {
+			continue
+		}
+
+		if strings.HasPrefix(line, "#!") {
+			execCmd = strings.Fields(line[2:])
+			// Everything remaining is stdin
+			var rest bytes.Buffer
+			for scanner.Scan() {
+				rest.Write(scanner.Bytes())
+				rest.WriteByte('\n')
+			}
+			stdinData = rest.Bytes()
+			break
+		}
+
+		if line == "" || strings.HasPrefix(line, "#") {
+			continue
+		}
+
+		switch {
+		case strings.HasPrefix(line, "ro "):
+			roPaths = append(roPaths, strings.TrimSpace(line[3:]))
+		case strings.HasPrefix(line, "rof "):
+			roFilePaths = append(roFilePaths, strings.TrimSpace(line[4:]))
+		case strings.HasPrefix(line, "rw "):
+			rwPaths = append(rwPaths, strings.TrimSpace(line[3:]))
+		case strings.HasPrefix(line, "rwf "):
+			rwFilePaths = append(rwFilePaths, strings.TrimSpace(line[4:]))
+		case strings.HasPrefix(line, "bind "):
+			s := strings.TrimSpace(line[5:])
+			port, err := strconv.ParseUint(s, 10, 16)
+			if err != nil {
+				panic("Invalid bind rule. Must be an integer. Ex: bind 8080")
+			}
+			netRules = append(netRules, landlock.BindTCP(uint16(port)))
+		case strings.HasPrefix(line, "connect "):
+			s := strings.TrimSpace(line[8:])
+			port, err := strconv.ParseUint(s, 10, 16)
+			if err != nil {
+				panic("Invalid connect rule. Must be an integer. Ex: connect 443")
+			}
+			netRules = append(netRules, landlock.ConnectTCP(uint16(port)))
+		default:
+			log.Fatalf("line %d: unknown directive: %s", lineNum, line)
+		}
+	}
+
+	if len(execCmd) == 0 {
+		log.Fatal("no exec target found (second #! line)")
+	}
+
+	// Apply landlock
+	var rules []landlock.Rule
+	if len(roPaths) > 0 {
+		rules = append(rules, landlock.RODirs(roPaths...))
+	}
+	if len(roFilePaths) > 0 {
+		rules = append(rules, landlock.ROFiles(roFilePaths...))
+	}
+	if len(rwPaths) > 0 {
+		rules = append(rules, landlock.RWDirs(rwPaths...))
+	}
+	if len(rwFilePaths) > 0 {
+		rules = append(rules, landlock.RWFiles(rwFilePaths...))
+	}
+	if len(netRules) > 0 {
+		rules = append(rules, netRules...)
+	}
+
+	if len(rules) > 0 {
+		err = landlock.V5.BestEffort().Restrict(rules...)
+		if err != nil {
+			log.Fatalf("failed to apply landlock: %v", err)
+		}
+	}
+
+	fullPath, err := exec.LookPath(execCmd[0])
+	if err != nil {
+		log.Fatalf("command not found: %v", err)
+	}
+
+	argv := append(execCmd, extraArgs...)
+
+	env := os.Environ()
+
+	// Replace file descriptor 0 with data after second shebang,
+	// so the exec'd process gets the script content instead
+	if len(stdinData) > 0 {
+		r, w, err := os.Pipe()
+		if err != nil {
+			log.Fatalf("failed to create pipe: %v", err)
+		}
+		go func() {
+			w.Write(stdinData)
+			w.Close()
+		}()
+		syscall.Dup2(int(r.Fd()), 0)
+		r.Close()
+	}
+
+	if err := syscall.Exec(fullPath, argv, env); err != nil {
+		log.Fatalf("failed to exec: %v", err)
+	}
+}