bastion 0.53.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
package main
// ops_view.go — what an op can see.
//
// An op's dry run needs no approval, so what it can READ is what keeps it
// harmless: a dry run's output and diff go back to the host. The view is
// closed by default. Inside the sandbox (dry run and apply alike) an op sees
// a fixed system base, the sandbox's own fresh mounts, and the paths its
// manifest declares — its effects and its reads. Everything else is covered
// with an empty read-only mount, files and directories alike
// (ops_exec_linux.go opCloseView), and a cover that cannot be put in place
// refuses the run.
//
// A declared path may not reach a sensitive path: a credential store this
// machine keeps (the built-in list below, plus any path the person adds at
// the machine in the record's sensitive_paths file). An op that names one is
// refused until the rung that seals its dry run to the person's own device,
// so the host never sees what such a file holds.
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// opViewBase is what every op sees besides what it declares: the programs
// and libraries an interpreter needs, and the /etc files that name users,
// hosts and certificates — none of them a secret.
var opViewBase = []string{
"/bin", "/sbin", "/lib", "/lib32", "/lib64", "/libx32",
"/usr/bin", "/usr/sbin", "/usr/lib", "/usr/lib32", "/usr/lib64", "/usr/libx32",
"/usr/libexec", "/usr/share", "/usr/local/bin", "/usr/local/lib", "/usr/local/share",
"/etc/passwd", "/etc/group", "/etc/hosts", "/etc/hostname", "/etc/resolv.conf",
"/etc/nsswitch.conf", "/etc/localtime", "/etc/os-release", "/etc/ld.so.cache",
"/etc/ld.so.conf", "/etc/ld.so.conf.d", "/etc/ssl/certs", "/etc/alternatives",
"/etc/protocols", "/etc/services", "/etc/mime.types",
}
// opViewSandbox is what the sandbox itself mounts fresh (proc, the empty
// scratch dirs, the op's own directory): always seen, never covered.
var opViewSandbox = []string{"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp", opSandboxMount}
// opSensitiveBuiltIn: the credential stores on a Linux machine. A `*` never
// crosses a `/`.
var opSensitiveBuiltIn = []string{
"/root",
"/home/*/.ssh", "/home/*/.docker", "/home/*/.git-credentials", "/home/*/.kube", "/home/*/.gnupg",
"/home/*/.aws", "/home/*/.netrc", "/home/*/.pgpass", "/home/*/.config",
"/etc/shadow", "/etc/shadow-", "/etc/gshadow", "/etc/gshadow-",
"/etc/ssh", "/etc/wireguard", "/etc/privatae", "/etc/letsencrypt", "/etc/ssl/private", "/etc/pki",
"/etc/systemd", // unit files and drop-ins carry database DSNs and tokens
"/etc/credstore", "/etc/credstore.encrypted", "/etc/docker", "/var/lib/docker",
"/etc/rancher", "/var/lib/rancher", "/etc/kubernetes", "/var/lib/kubelet",
"/var/lib/postgresql", "/var/lib/mysql", "/var/lib/cloud",
}
// opSensitiveFileName, in this machine's record (masked from every op), lists
// more sensitive paths, one absolute path per line; '#' starts a comment.
const opSensitiveFileName = "sensitive_paths"
// opSensitivePaths is the built-in list plus the machine's own, each also as
// it resolves when it is a plain path. A machine list that cannot be read or
// holds a line that is not an absolute path refuses every op: a store the
// person named must never quietly fall out of the list.
func opSensitivePaths() ([]string, error) {
out := []string{}
add := func(p string) {
c := filepath.Clean(p)
out = append(out, c)
if !isGlob(c) {
if r := resolveExisting(c); r != c {
out = append(out, r)
}
}
}
for _, p := range opSensitiveBuiltIn {
add(p)
}
d, err := configDirPath()
if err != nil || d == "" {
return nil, errors.New("this machine's own record cannot be located; ops are refused")
}
f, err := os.Open(filepath.Join(d, opSensitiveFileName))
if errors.Is(err, os.ErrNotExist) {
return out, nil
}
if err != nil {
return nil, fmt.Errorf("this machine's %s cannot be read; ops are refused: %v", opSensitiveFileName, err)
}
defer f.Close()
sc := bufio.NewScanner(f)
n := 0
for sc.Scan() {
n++
line := strings.TrimSpace(sc.Text())
if i := strings.Index(line, "#"); i >= 0 {
line = strings.TrimSpace(line[:i])
}
if line == "" {
continue
}
if !filepath.IsAbs(line) || strings.Contains(line, "..") || filepath.Clean(line) == "/" {
return nil, fmt.Errorf("this machine's %s line %d is not an absolute path; ops are refused", opSensitiveFileName, n)
}
add(line)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("this machine's %s cannot be read; ops are refused: %v", opSensitiveFileName, err)
}
return out, nil
}
// opSegGlob: does one path segment carry a glob character?
func opSegGlob(s string) bool { return strings.ContainsAny(s, "*?[") }
// opSegMatch: does a pattern segment (a glob or a literal) match a name?
func opSegMatch(pat, name string) bool {
if !opSegGlob(pat) {
return pat == name
}
ok, err := filepath.Match(pat, name)
return err == nil && ok
}
// opSegCouldMatch: could two segments, either of which may be a glob, name
// the same thing? Two globs are assumed to overlap: this decides a refusal,
// so it errs toward refusing.
func opSegCouldMatch(a, b string) bool {
switch ga, gb := opSegGlob(a), opSegGlob(b); {
case !ga && !gb:
return a == b
case ga && !gb:
return opSegMatch(a, b)
case !ga && gb:
return opSegMatch(b, a)
default:
return true
}
}
// opPathsOverlap: is one path (or glob) at, under or above the other?
// Segment by segment over the shorter of the two.
func opPathsOverlap(a, b string) bool {
as, bs := pathSegments(a), pathSegments(b)
n := len(as)
if len(bs) < n {
n = len(bs)
}
for i := 0; i < n; i++ {
if !opSegCouldMatch(as[i], bs[i]) {
return false
}
}
return true
}
// opSensitiveMatch names the sensitive path a declared path (or glob) is at,
// under or above — as written and, for a plain path, as it resolves here —
// or "" when it reaches none.
func opSensitiveMatch(p string) (string, error) {
return opSensitiveMatchForms(opDeclaredForms(p))
}
// opSensitiveMatchForms judges spellings already resolved, as they are: the
// run judges exactly the list it hands the sandbox, so a link that moves
// between a check and the view cannot slip a store past the check.
func opSensitiveMatchForms(forms []string) (string, error) {
sens, err := opSensitivePaths()
if err != nil {
return "", err
}
for _, f := range forms {
for _, s := range sens {
if opPathsOverlap(f, s) {
return s, nil
}
}
}
return "", nil
}
// opDeclaredForms is a declared path as written and, for a plain path, as it
// resolves here now.
func opDeclaredForms(p string) []string {
c := filepath.Clean(p)
forms := []string{c}
if !isGlob(c) {
if r := resolveExisting(c); r != c {
forms = append(forms, r)
}
}
return forms
}
// opSensitiveRefusal is the sentence for an op that names a sensitive path.
func opSensitiveRefusal(declared, sensitive string) error {
return fmt.Errorf("%s reaches a sensitive path on this machine (%s): an op that reads or changes a credential store is refused until its dry run can be sealed to your own device", declared, sensitive)
}
// opViewDeclared is what the op declared, each plain path resolved ONCE: as
// written and as it resolves now, so a declared path that is a link still
// shows what it names. The run checks exactly this list against the stores
// and hands exactly this list to the sandbox.
func opViewDeclared(m *opManifest) []string {
var out []string
for _, d := range append(append([]string{}, m.Effects.Files...), m.Reads...) {
out = append(out, opDeclaredForms(d)...)
}
return out
}
// opViewVisible is everything an op may see: the base, the sandbox's own
// mounts, and the declared list (opViewDeclared).
func opViewVisible(declared []string) []string {
out := append([]string{}, opViewBase...)
out = append(out, opViewSandbox...)
return append(out, declared...)
}
type opViewAction int
const (
opViewHide opViewAction = iota // cover it
opViewKeep // it and everything under it are seen
opViewDescend // a visible path lies below it: look inside
)
// opViewDecide says what the closed view does with one path, given the
// visible list split into segments. Everything under a visible path is seen;
// a folder on the way to one is entered; anything else is covered.
func opViewDecide(p string, visible [][]string) opViewAction {
ps := pathSegments(p)
descend := false
for _, v := range visible {
n := len(v)
if len(ps) < n {
n = len(ps)
}
ok := true
for i := 0; i < n; i++ {
if !opSegMatch(v[i], ps[i]) {
ok = false
break
}
}
if !ok {
continue
}
if len(ps) >= len(v) {
return opViewKeep
}
descend = true
}
if descend {
return opViewDescend
}
return opViewHide
}
func opViewSegments(visible []string) [][]string {
out := make([][]string, 0, len(visible))
for _, v := range visible {
out = append(out, pathSegments(v))
}
return out
}