bastion 0.53.2
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user