800 lines
38 KiB
Go
800 lines
38 KiB
Go
//go:build !windows
|
|
|
|
package main
|
|
|
|
// ops_test.go — rung 6: the op format, the
|
|
// two terms, the store, and the executor's mutation suite. The unprivileged
|
|
// tests cover the manifest, params, bounds, digest, terms and the upper-dir
|
|
// reader; the sandbox itself runs where the daemon would (root, Linux,
|
|
// overlay) and is skipped elsewhere with the reason.
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const opTestManifest = `name: write-hello
|
|
version: 1.0.0
|
|
summary: Write hello.txt under the test folder.
|
|
target: machine
|
|
privilege: root
|
|
interpreter: /bin/sh
|
|
time_limit: 30
|
|
params:
|
|
name: {type: string, max_len: 32}
|
|
count: {type: integer, min: 1, max: 5}
|
|
kind: {type: enum, values: [a, b]}
|
|
effects:
|
|
files: ["%s/"]
|
|
modes: [dry_run, apply, verify]
|
|
undo: none
|
|
`
|
|
|
|
// opTestRun is the op's program: the params arrive as JSON on stdin, the
|
|
// mode as --mode; it writes one file under the effects folder (dry_run and
|
|
// apply alike — the sandbox is what makes the dry run harmless), and verify
|
|
// checks it is there. EXTRA lets a test make it misbehave.
|
|
const opTestRun = `#!/bin/sh
|
|
read -r params
|
|
mode="$2"
|
|
case "$mode" in
|
|
dry_run|apply)
|
|
mkdir -p "%s"
|
|
printf 'hello %%s\n' "$params" > "%s/hello.txt"
|
|
%s
|
|
;;
|
|
verify)
|
|
test -f "%s/hello.txt"
|
|
;;
|
|
esac
|
|
`
|
|
|
|
func opFixture(effectsRoot, extra string) []opFile {
|
|
man := fmt.Sprintf(opTestManifest, effectsRoot)
|
|
run := fmt.Sprintf(opTestRun, effectsRoot, effectsRoot, extra, effectsRoot)
|
|
return []opFile{
|
|
{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte(man))},
|
|
{Path: "run", Mode: 0o755, B64: base64.StdEncoding.EncodeToString([]byte(run))},
|
|
}
|
|
}
|
|
|
|
// opTestBox: an anchored box whose pairing dir pins a signing key we hold.
|
|
func opTestBox(t *testing.T) (dcn string, priv ed25519.PrivateKey) {
|
|
t.Helper()
|
|
setupBox(t)
|
|
dcn = t.TempDir()
|
|
pub, priv, _ := ed25519.GenerateKey(nil)
|
|
if err := dcnSaveFile(dcn, &dcnGrantFile{Stores: map[string]dcnGrantRec{},
|
|
VerifyPubkey: base64.StdEncoding.EncodeToString(pub)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return dcn, priv
|
|
}
|
|
|
|
func opTerm(kind, sha, params, bounds string, uses int, ttl time.Duration, privilege string) map[string]any {
|
|
hn, _ := os.Hostname()
|
|
m := map[string]any{"kind": kind, "entity": 9001, "op": "write-hello", "version": "1.0.0", "sha256": sha,
|
|
"server": hn, "privilege": privilege, "expires": time.Now().Add(ttl).UTC().Format(time.RFC3339),
|
|
"uses": uses, "nonce": "0123456789abcdef0123456789abcdef"}
|
|
if kind == opKindRun {
|
|
m["params"] = params
|
|
} else {
|
|
m["bounds"] = bounds
|
|
}
|
|
return m
|
|
}
|
|
|
|
func bundleB64(files []opFile) string {
|
|
raw, _ := json.Marshal(files)
|
|
return base64.StdEncoding.EncodeToString(raw)
|
|
}
|
|
|
|
// ── the manifest ────────────────────────────────────────────────────────────
|
|
|
|
func TestOpManifestRefusesWhatRungSixDoesNotCarry(t *testing.T) {
|
|
ownRecord, _ := configDirPath()
|
|
good := fmt.Sprintf(opTestManifest, "/srv/optest")
|
|
if _, err := parseOpManifest([]byte(good)); err != nil {
|
|
t.Fatalf("the fixture must parse: %v", err)
|
|
}
|
|
bad := map[string]string{
|
|
"a name with capitals": strings.Replace(good, "name: write-hello", "name: WriteHello", 1),
|
|
"no version shape": strings.Replace(good, "version: 1.0.0", "version: v1", 1),
|
|
"an interpreter of its own": strings.Replace(good, "interpreter: /bin/sh", "interpreter: /usr/bin/perl", 1),
|
|
"a time limit past the box": strings.Replace(good, "time_limit: 30", "time_limit: 6000", 1),
|
|
"a relative effect": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["srv/optest/"]`, 1),
|
|
"the root as an effect": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/"]`, 1),
|
|
"an effect under /proc": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/proc/sys/kernel"]`, 1),
|
|
"a placeholder in effects": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/srv/{name}/"]`, 1),
|
|
"a unit effect": strings.Replace(good, "effects:\n", "effects:\n units: [nginx]\n", 1),
|
|
"no verify mode": strings.Replace(good, "modes: [dry_run, apply, verify]", "modes: [dry_run, apply]", 1),
|
|
"undo unstated": strings.Replace(good, "undo: none", "undo: maybe", 1),
|
|
"a host-target op": strings.Replace(good, "target: machine", "target: host", 1),
|
|
"an integer with no bounds": strings.Replace(good, "count: {type: integer, min: 1, max: 5}", "count: {type: integer}", 1),
|
|
"a string with no max": strings.Replace(good, "name: {type: string, max_len: 32}", "name: {type: string}", 1),
|
|
"the box's own record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+ownRecord+`/"]`, 1),
|
|
"a glob into the record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+ownRecord+`/dcn/*"]`, 1),
|
|
"a folder holding the record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+filepath.Dir(ownRecord)+`/"]`, 1),
|
|
"a glob into the record (json)": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+ownRecord+`/*.json"]`, 1),
|
|
"a glob over that folder": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+filepath.Dir(ownRecord)+`/*"]`, 1),
|
|
"a glob over its parent": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+filepath.Dir(filepath.Dir(ownRecord))+`/*/*"]`, 1),
|
|
"a glob naming the record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/privatae-*"]`, 1),
|
|
"a glob through the record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/*/observe.kubeconfig"]`, 1),
|
|
"a ? into the record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/privatae-bastio?"]`, 1),
|
|
"a class into the record": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/privatae-bastio[n]/x"]`, 1),
|
|
"a class holding a slash": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/privatae-bastion[/]x"]`, 1),
|
|
"a glob under /var/lib/privatae": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/var/lib/privatae/*/x"]`, 1),
|
|
"an escaped letter": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/privatae-bastio\\n"]`, 1),
|
|
"an escaped slash": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/etc/privatae-bastion\\/observe.kubeconfig"]`, 1),
|
|
"an escape past /proc": strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["/pro\\c/sys"]`, 1),
|
|
}
|
|
// A glob beside the record is fine: a `*` never crosses a `/`, so
|
|
// /etc/*.conf names nothing inside /etc/privatae-bastion — and the folder
|
|
// itself is refused at run time and masked in the sandbox.
|
|
// A class that would match a `/` under a whole-path filepath.Match is
|
|
// harmless here: effectCovers matches segment by segment with equal
|
|
// counts, so these can name only a sibling of the record, never inside
|
|
// it — and they parse.
|
|
// (/var/lib/*/state used to stand here; it could name /var/lib/rancher/state,
|
|
// a credential store, and is refused as sensitive now — ops_view_test.go.)
|
|
for _, ok := range []string{`/etc/*.conf`, `/opt/*/state`, `/etc/privatae-bastion[^a]observe.kubeconfig`,
|
|
`/etc/privatae-bastion[.-0]observe.kubeconfig`, `/var/lib/privatae/bastion[^x]*`} {
|
|
if _, err := parseOpManifest([]byte(strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+ok+`"]`, 1))); err != nil {
|
|
t.Errorf("a glob beside the record is allowed: %s: %v", ok, err)
|
|
}
|
|
}
|
|
for _, folder := range []string{`/etc/`, `/var/lib/`, `/var/lib/privatae/`} {
|
|
if _, err := parseOpManifest([]byte(strings.Replace(good, `files: ["/srv/optest/"]`, `files: ["`+folder+`"]`, 1))); err == nil {
|
|
t.Errorf("a folder holding this machine's record is refused: %s", folder)
|
|
}
|
|
}
|
|
for name, m := range bad {
|
|
if _, err := parseOpManifest([]byte(m)); err == nil {
|
|
t.Errorf("%s must be refused", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpParamsAndBoundsAreChecked(t *testing.T) {
|
|
m, _ := parseOpManifest([]byte(fmt.Sprintf(opTestManifest, "/srv/optest")))
|
|
ok := `{"name":"x","count":3,"kind":"a"}`
|
|
if _, err := checkParams(m, ok); err != nil {
|
|
t.Fatalf("good params: %v", err)
|
|
}
|
|
for name, p := range map[string]string{
|
|
"missing": `{"name":"x","count":3}`,
|
|
"undeclared": `{"name":"x","count":3,"kind":"a","extra":1}`,
|
|
"out of range": `{"name":"x","count":9,"kind":"a"}`,
|
|
"not an int": `{"name":"x","count":2.5,"kind":"a"}`,
|
|
"bad enum": `{"name":"x","count":3,"kind":"z"}`,
|
|
"string too big": `{"name":"` + strings.Repeat("y", 40) + `","count":3,"kind":"a"}`,
|
|
"not an object": `[1,2]`,
|
|
} {
|
|
if _, err := checkParams(m, p); err == nil {
|
|
t.Errorf("%s must be refused", name)
|
|
}
|
|
}
|
|
// A path root written with a trailing slash accepts the bare root.
|
|
pm := &opManifest{Params: map[string]opParam{"dest": {Type: "path", Root: "/srv/data/"}}}
|
|
if _, err := checkParams(pm, `{"dest":"/srv/data"}`); err != nil {
|
|
t.Fatalf("the root itself is under the root: %v", err)
|
|
}
|
|
if _, err := checkParams(pm, `{"dest":"/srv/datax"}`); err == nil {
|
|
t.Fatal("a sibling is not under the root")
|
|
}
|
|
vals, _ := checkParams(m, ok)
|
|
b, err := parseBounds(m, `{"count":{"min":1,"max":2},"kind":{"values":["a"]},"name":{"values":["x"]}}`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := checkBounds(m, b, vals); err == nil {
|
|
t.Fatal("count 3 is outside the approved 1..2")
|
|
}
|
|
vals2, _ := checkParams(m, `{"name":"x","count":2,"kind":"a"}`)
|
|
if err := checkBounds(m, b, vals2); err != nil {
|
|
t.Fatalf("inside the bounds: %v", err)
|
|
}
|
|
if _, err := parseBounds(m, `{"nothing":{"min":1}}`); err == nil {
|
|
t.Fatal("a bound naming no param is refused")
|
|
}
|
|
// The card shows the bounds as sent, so every field must be one the box
|
|
// enforces for that param's type, inside the op's own bound.
|
|
for name, bad := range map[string]string{
|
|
"a field nobody reads": `{"count":{"min":1,"max":2,"root":"/x"}}`,
|
|
"values on an integer": `{"count":{"values":["1"]}}`,
|
|
"min/max on an enum": `{"kind":{"min":1}}`,
|
|
"wider than the op": `{"count":{"min":0,"max":9}}`,
|
|
"a value the op lacks": `{"kind":{"values":["z"]}}`,
|
|
"a string past max_len": `{"name":{"values":["` + strings.Repeat("y", 40) + `"]}}`,
|
|
"an empty values list": `{"kind":{"values":[]}}`,
|
|
"an integer bound with nothing": `{"count":{}}`,
|
|
} {
|
|
if _, err := parseBounds(m, bad); err == nil {
|
|
t.Errorf("%s must be refused", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpDigestIsTheFramedSHA256(t *testing.T) {
|
|
files := []opFile{
|
|
{Path: "run", Mode: 0o755, B64: base64.StdEncoding.EncodeToString([]byte("b"))},
|
|
{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte("a"))},
|
|
}
|
|
got, err := opDirDigest(files)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Pinned against nac_ops.op_dir_digest on the pod (the same vector).
|
|
if got != "3392b4eb3a6c228079e011fed9e829b250d93a5ce2ff1ed5b4a3f1e8a0cd774d" {
|
|
t.Fatalf("fixed vector: %s", got)
|
|
}
|
|
files[0].B64 = base64.StdEncoding.EncodeToString([]byte("c"))
|
|
if got2, _ := opDirDigest(files); got2 == got {
|
|
t.Fatal("one changed byte is a different op")
|
|
}
|
|
if err := opBundleOK([]opFile{{Path: "../x", B64: "YQ=="}, {Path: "op.yaml", B64: "YQ=="}, {Path: "run", B64: "YQ=="}}); err == nil {
|
|
t.Fatal("a path that escapes is refused")
|
|
}
|
|
if err := opBundleOK([]opFile{{Path: "op.yaml", B64: "YQ=="}}); err == nil {
|
|
t.Fatal("run is required")
|
|
}
|
|
}
|
|
|
|
func TestEffectCovers(t *testing.T) {
|
|
eff := []string{"/etc/app/", "/var/log/app/*.log", "/etc/one.conf"}
|
|
for p, want := range map[string]bool{
|
|
"/etc/app/x.conf": true,
|
|
"/etc/app/sub/y": true,
|
|
"/etc/app": true,
|
|
"/etc/application/x": false,
|
|
"/var/log/app/a.log": true,
|
|
"/var/log/app/sub/a.log": false,
|
|
"/etc/one.conf": true,
|
|
"/etc/one.conf.bak": false,
|
|
"/": false,
|
|
} {
|
|
if effectCovers(eff, p) != want {
|
|
t.Errorf("%s: want %v", p, want)
|
|
}
|
|
}
|
|
// A class never spans a /: the glob and the path are matched segment by
|
|
// segment with equal counts (the gate uses the same definition).
|
|
for p, want := range map[string]bool{
|
|
"/etc/x/y": false, // [^a] and [+-0] match a / under filepath.Match; never across segments
|
|
"/etc/xby": true,
|
|
"/etc/x-y": true,
|
|
"/etc/xay": false,
|
|
"/etc/x/": false,
|
|
"/etc/x_yz": false,
|
|
} {
|
|
if effectCovers([]string{"/etc/x[^a]y", "/etc/x[+-0]y"}, p) != want {
|
|
t.Errorf("class: %s: want %v", p, want)
|
|
}
|
|
}
|
|
if effectCovers([]string{"/var/log/*/*.log"}, "/var/log/app/a.log") != true || effectCovers([]string{"/var/log/*/*.log"}, "/var/log/a.log") != false {
|
|
t.Error("a glob covers only paths with the same number of segments")
|
|
}
|
|
}
|
|
|
|
func TestOpRunStatusReadsTheCommitNotTheVerify(t *testing.T) {
|
|
dcn, _ := opTestBox(t)
|
|
_ = os.MkdirAll(opsRunsDir(dcn), 0o700)
|
|
write := func(id, nonce, kind, mode, errText string, commit []string) {
|
|
raw, _ := json.Marshal(opRunRecord{ID: id, At: time.Now().UTC().Format(time.RFC3339), Kind: kind, Op: "x", SHA256: strings.Repeat("a", 64),
|
|
Mode: mode, Nonce: nonce, Error: errText, Commit: commit, Report: &opReport{ChangesSHA256: strings.Repeat("c", 64)}})
|
|
_ = os.WriteFile(filepath.Join(opsRunsDir(dcn), id+".json"), raw, 0o600)
|
|
}
|
|
write("r1", "n-verify", opKindRun, opModeApply, "verify: the sandbox could not start", []string{"created /srv/x"})
|
|
write("r2", "n-commit", opKindRun, opModeApply, "commit: disk full (1 change(s) landed before it)", []string{"created /srv/x"})
|
|
write("r3", "n-grant", opKindGrant, opModeApply, "", []string{"created /srv/y"})
|
|
for nonce, want := range map[string]bool{"n-verify": true, "n-commit": false} {
|
|
st, _, code := driveJob(t, tAnchor, mgmtJob{Op: "op_run_status", Args: []string{nonce}}, "", dcn)
|
|
var status map[string]any
|
|
_ = json.Unmarshal([]byte(st), &status)
|
|
if code != 0 || status["found"] != true || status["applied"] != want {
|
|
t.Fatalf("%s: applied=%v wanted %v: %s", nonce, status["applied"], want, st)
|
|
}
|
|
}
|
|
st, _, _ := driveJob(t, tAnchor, mgmtJob{Op: "op_run_status", Args: []string{"n-grant"}}, "", dcn)
|
|
var status map[string]any
|
|
_ = json.Unmarshal([]byte(st), &status)
|
|
if status["found"] != false {
|
|
t.Fatalf("a run under a grant carries the grant's nonce and never answers here: %s", st)
|
|
}
|
|
}
|
|
|
|
// ── the terms: the mutation suite ───────────────────────────────────────────
|
|
|
|
func TestOpTermsMutationsAreEachRefused(t *testing.T) {
|
|
dcn, priv := opTestBox(t)
|
|
files := opFixture("/srv/optest", "")
|
|
sha, _ := opDirDigest(files)
|
|
good := opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root")
|
|
terms, sig := signTerms(t, priv, good)
|
|
if _, err := verifyOpTerms(dcn, terms, sig, opKindRun); err != nil {
|
|
t.Fatalf("good terms verify: %v", err)
|
|
}
|
|
hn, _ := os.Hostname()
|
|
mut := func(f func(m map[string]any)) []string {
|
|
m := opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root")
|
|
f(m)
|
|
return signTermsPair(t, priv, m)
|
|
}
|
|
cases := map[string]struct {
|
|
terms, sig string
|
|
kind string
|
|
want string
|
|
}{
|
|
"expired": {"", "", opKindRun, "expired"},
|
|
"open-ended": {"", "", opKindRun, "no expiry"},
|
|
"too long-lived": {"", "", opKindRun, "longer than"},
|
|
"two uses": {"", "", opKindRun, "one use"},
|
|
"other server": {"", "", opKindRun, "this box is"},
|
|
"malformed sha256": {"", "", opKindRun, "malformed sha256"},
|
|
"no nonce": {"", "", opKindRun, "no nonce"},
|
|
"wrong kind": {"", "", opKindGrant, "not \"op_grant\""},
|
|
"root grant": {"", "", opKindGrant, "never reusable"},
|
|
"grant no bounds": {"", "", opKindGrant, "carries its bounds"},
|
|
"grant too many uses": {"", "", opKindGrant, "uses 1.."},
|
|
}
|
|
set := func(name string, pair []string) { c := cases[name]; c.terms, c.sig = pair[0], pair[1]; cases[name] = c }
|
|
set("expired", mut(func(m map[string]any) { m["expires"] = time.Now().Add(-time.Minute).UTC().Format(time.RFC3339) }))
|
|
set("open-ended", mut(func(m map[string]any) { m["expires"] = "" }))
|
|
set("too long-lived", mut(func(m map[string]any) { m["expires"] = time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339) }))
|
|
set("two uses", mut(func(m map[string]any) { m["uses"] = 2 }))
|
|
set("other server", mut(func(m map[string]any) { m["server"] = hn + "-other" }))
|
|
set("malformed sha256", mut(func(m map[string]any) { m["sha256"] = strings.Repeat("z", 64) }))
|
|
set("no nonce", mut(func(m map[string]any) { m["nonce"] = "" }))
|
|
set("wrong kind", mut(func(m map[string]any) {}))
|
|
set("root grant", signTermsPair(t, priv, opTerm(opKindGrant, sha, "", `{}`, 5, time.Hour, "root")))
|
|
set("grant no bounds", signTermsPair(t, priv, opTerm(opKindGrant, sha, "", "", 5, time.Hour, "user")))
|
|
set("grant too many uses", signTermsPair(t, priv, opTerm(opKindGrant, sha, "", `{}`, 5000, time.Hour, "user")))
|
|
for name, c := range cases {
|
|
_, err := verifyOpTerms(dcn, c.terms, c.sig, c.kind)
|
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("%s: want refusal containing %q, got %v", name, c.want, err)
|
|
}
|
|
}
|
|
// Signed by another key.
|
|
_, other, _ := ed25519.GenerateKey(nil)
|
|
ot, os_ := signTerms(t, other, good)
|
|
if _, err := verifyOpTerms(dcn, ot, os_, opKindRun); err == nil || !strings.Contains(err.Error(), "does not verify") {
|
|
t.Fatalf("another key: %v", err)
|
|
}
|
|
// Tampered after signing.
|
|
raw, _ := base64.StdEncoding.DecodeString(terms)
|
|
tampered := base64.StdEncoding.EncodeToString([]byte(strings.Replace(string(raw), `"uses":1`, `"uses":2`, 1)))
|
|
if _, err := verifyOpTerms(dcn, tampered, sig, opKindRun); err == nil {
|
|
t.Fatal("tampered terms must not verify")
|
|
}
|
|
}
|
|
|
|
// ── sync, and the grant's install spends the approval ───────────────────────
|
|
|
|
func TestOpSyncHoldsTheOpAndInstallsAGrantOnce(t *testing.T) {
|
|
dcn, priv := opTestBox(t)
|
|
enrollFactor(t, dcn)
|
|
files := opFixture("/srv/optest", "")
|
|
sha, _ := opDirDigest(files)
|
|
// The bundle must hash to the sha256 the terms name.
|
|
wrong := signTermsPair(t, priv, opTerm(opKindRun, strings.Repeat("0", 64), `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root"))
|
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: []string{wrong[0], wrong[1], bundleB64(files)}}, "", dcn); code != 126 || !strings.Contains(errOut, "does not hash") {
|
|
t.Fatalf("a bundle that does not hash to the terms' sha256 is refused: %d %q", code, errOut)
|
|
}
|
|
run := signTermsPair(t, priv, opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root"))
|
|
out, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: []string{run[0], run[1], bundleB64(files)}}, "", dcn)
|
|
if code != 0 || !strings.Contains(out, `"held"`) {
|
|
t.Fatalf("op_sync with op_run terms holds the op: %d %s %s", code, out, errOut)
|
|
}
|
|
if _, err := opLoad(dcn, sha); err != nil {
|
|
t.Fatalf("held and re-hashed: %v", err)
|
|
}
|
|
// A grant installs under the person's approval — spent once.
|
|
gt := opTerm(opKindGrant, sha, "", `{"count":{"min":1,"max":2}}`, 3, time.Hour, "user")
|
|
// The fixture is privilege root; a grant is user only, and the manifest must agree.
|
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, gt), bundleB64(files))}, "", dcn); code != 126 || !strings.Contains(errOut, "disagree") {
|
|
t.Fatalf("a user grant for a root manifest is refused: %d %q", code, errOut)
|
|
}
|
|
ufiles := []opFile{
|
|
{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte(strings.Replace(fmt.Sprintf(opTestManifest, "/srv/optest"), "privilege: root", "privilege: user", 1)))},
|
|
files[1],
|
|
}
|
|
usha, _ := opDirDigest(ufiles)
|
|
gt["sha256"] = usha
|
|
purpose, err := purposeForOpGrant(&opTerms{Server: gt["server"].(string), Op: "write-hello", SHA256: usha,
|
|
Bounds: gt["bounds"].(string), Expires: gt["expires"].(string), Uses: 3})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, gt), bundleB64(ufiles))}, "", dcn); code != 126 || !strings.Contains(errOut, "approval") {
|
|
t.Fatalf("a grant without the person's approval is refused: %d %q", code, errOut)
|
|
}
|
|
raw, _ := b32decode(tSecret)
|
|
approval, err := stepupIssueApproval(dcn, hotp(raw, time.Now().Unix()/stepupPeriod), purpose)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gt["approval"] = approval
|
|
out, errOut, code = driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, gt), bundleB64(ufiles))}, "", dcn)
|
|
if code != 0 || !strings.Contains(out, `"grant"`) {
|
|
t.Fatalf("an approved grant installs: %d %s %s", code, out, errOut)
|
|
}
|
|
if _, rec, err := opsHeldGrant(dcn, gt["nonce"].(string)); err != nil || rec.UsesLeft != 3 {
|
|
t.Fatalf("held with its uses: %v %v", err, rec)
|
|
}
|
|
// Dropped at the console, it cannot come back under the same approval.
|
|
if ok, _ := opsDropGrant(dcn, gt["nonce"].(string), "test"); !ok {
|
|
t.Fatal("drop")
|
|
}
|
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, gt), bundleB64(ufiles))}, "", dcn); code != 126 || !strings.Contains(errOut, "already used") {
|
|
t.Fatalf("a dropped grant is not re-installed under a spent approval: %d %q", code, errOut)
|
|
}
|
|
// The device signs op kinds (they name the machine) and the card reads.
|
|
words := describeRequest(stepupRequest{Details: map[string]any{"kind": opKindGrant, "op": "write-hello", "version": "1.0.0",
|
|
"server": gt["server"], "privilege": "user", "bounds": gt["bounds"], "uses": float64(3), "expires": gt["expires"],
|
|
"sha256": usha, "summary": "Write hello.txt.", "effects": []any{"/srv/optest/"}}})
|
|
for _, want := range []string{"FROM NOW ON", "write-hello 1.0.0", "up to 3 runs", "/srv/optest/", "identity: sha256"} {
|
|
if !strings.Contains(words, want) {
|
|
t.Fatalf("the card must say %q: %s", want, words)
|
|
}
|
|
}
|
|
if p, err := purposeFromDetails(map[string]any{"kind": opKindGrant, "op": "write-hello", "version": "1.0.0",
|
|
"server": gt["server"], "privilege": "user", "bounds": gt["bounds"], "uses": float64(3), "expires": gt["expires"], "sha256": usha}); err != nil || p != purpose {
|
|
t.Fatalf("the details derive the same purpose the box enforces: %v %s", err, p)
|
|
}
|
|
}
|
|
|
|
func signTermsPair(t *testing.T, priv ed25519.PrivateKey, m map[string]any) []string {
|
|
t.Helper()
|
|
a, b := signTerms(t, priv, m)
|
|
return []string{a, b}
|
|
}
|
|
|
|
func TestOffLimitsCarryBothSpellingsAndRefuseWhenUnreadable(t *testing.T) {
|
|
realDir := t.TempDir()
|
|
link := filepath.Join(t.TempDir(), "cfg-link")
|
|
if err := os.Symlink(realDir, link); err != nil {
|
|
t.Skip("no symlinks here")
|
|
}
|
|
t.Setenv("XDG_CONFIG_HOME", link)
|
|
offs, err := opOffLimits()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := map[string]bool{filepath.Join(link, "privatae-bastion"): false, filepath.Join(realDir, "privatae-bastion"): false}
|
|
for _, o := range offs {
|
|
if _, ok := want[o]; ok {
|
|
want[o] = true
|
|
}
|
|
}
|
|
for p, seen := range want {
|
|
if !seen {
|
|
t.Fatalf("the record is protected as spelled and as it resolves; missing %s in %v", p, offs)
|
|
}
|
|
}
|
|
// The other key material this daemon keeps on disk is in the set too.
|
|
for _, p := range []string{"/var/lib/privatae/freeagent", "/etc/privatae-bastion", "/var/lib/privatae/bastion"} {
|
|
found := false
|
|
for _, o := range offs {
|
|
if o == p {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("%s is off limits; got %v", p, offs)
|
|
}
|
|
}
|
|
// A pairing file that does not parse: the record cannot be enumerated,
|
|
// and every op is refused rather than one DCN dir left unprotected.
|
|
pd, _ := pairingsDir()
|
|
_ = os.MkdirAll(pd, 0o700)
|
|
_ = os.WriteFile(filepath.Join(pd, "broken.json"), []byte("{not json"), 0o600)
|
|
if _, err := opOffLimits(); err == nil {
|
|
t.Fatal("unreadable pairings refuse ops")
|
|
}
|
|
if err := effectOK("/srv/x/"); err == nil {
|
|
t.Fatal("an effect cannot be judged when the record cannot be enumerated")
|
|
}
|
|
}
|
|
|
|
// ── the sandbox itself: root, Linux, overlay ────────────────────────────────
|
|
|
|
func requireSandbox(t *testing.T) {
|
|
t.Helper()
|
|
if ok, why := opSandboxAvailable(); !ok {
|
|
t.Skip("no sandbox here: " + why)
|
|
}
|
|
// The fixtures' effects are folders under here; the folder itself must
|
|
// exist so creating it is not a change outside the effects.
|
|
if err := os.MkdirAll(opTestRoot, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
const opTestRoot = "/srv/privatae-optest"
|
|
|
|
// opProbeRun is a python3 op that tries what the sandbox must deny: a dial
|
|
// to the host's sshd and a write under /proc. It prints a marker for each
|
|
// one that succeeds.
|
|
const opProbeRun = `#!/usr/bin/python3
|
|
import socket, sys
|
|
sys.stdin.readline()
|
|
try:
|
|
socket.create_connection(("127.0.0.1", 22), 1).close(); print("NET-OPEN")
|
|
except Exception as e:
|
|
print("net:", e)
|
|
try:
|
|
open("/proc/sys/kernel/hostname", "w").write("x"); print("PROC-WRITTEN")
|
|
except Exception as e:
|
|
print("proc:", e)
|
|
`
|
|
|
|
func TestOpThatWritesOutsideItsEffectsIsRefusedBeforeAnyoneIsAsked(t *testing.T) {
|
|
requireSandbox(t)
|
|
dcn, priv := opTestBox(t)
|
|
root := filepath.Join(opTestRoot, fmt.Sprintf("e%d", time.Now().UnixNano()))
|
|
outside := root + "-outside"
|
|
t.Cleanup(func() { _ = os.RemoveAll(root); _ = os.RemoveAll(outside) })
|
|
cases := map[string]string{
|
|
"a file outside the effects": fmt.Sprintf(`mkdir -p %s; echo x > %s/leak`, outside, outside),
|
|
"a symlink inside them": fmt.Sprintf(`ln -s /etc/passwd %s/link`, root),
|
|
"a hardlink inside them": fmt.Sprintf(`ln %s/hello.txt %s/hard`, root, root),
|
|
"a write to /etc": `echo x >> /etc/hostname`,
|
|
}
|
|
for name, extra := range cases {
|
|
files := opFixture(root, extra)
|
|
sha, _ := opDirDigest(files)
|
|
run := opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root")
|
|
pair := signTermsPair(t, priv, run)
|
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn); code != 0 {
|
|
t.Fatalf("%s: sync: %s", name, errOut)
|
|
}
|
|
_, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn)
|
|
if code != 126 || !strings.Contains(errOut, "refused") {
|
|
t.Errorf("%s: the dry run must be refused: %d %q", name, code, errOut)
|
|
}
|
|
if _, err := os.Stat(outside + "/leak"); err == nil {
|
|
t.Errorf("%s: nothing lands from a dry run", name)
|
|
}
|
|
_ = os.RemoveAll(root)
|
|
}
|
|
// The sandbox has no network and a masked /proc: an op that dials the
|
|
// host's sshd or writes there fails inside, and nothing reaches out.
|
|
if c, err := net.DialTimeout("tcp", "127.0.0.1:22", time.Second); err != nil {
|
|
t.Logf("no sshd on loopback here; the network half of the probe is not exercised: %v", err)
|
|
} else {
|
|
c.Close()
|
|
}
|
|
man := strings.Replace(fmt.Sprintf(opTestManifest, root), "interpreter: /bin/sh", "interpreter: python3", 1)
|
|
files := []opFile{
|
|
{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte(man))},
|
|
{Path: "run", Mode: 0o755, B64: base64.StdEncoding.EncodeToString([]byte(opProbeRun))},
|
|
}
|
|
sha, _ := opDirDigest(files)
|
|
run := opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root")
|
|
pair := signTermsPair(t, priv, run)
|
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn); code != 0 {
|
|
t.Fatalf("probe sync: %s", errOut)
|
|
}
|
|
out, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn)
|
|
if code != 0 {
|
|
t.Fatalf("the probe op changes nothing and must run: %d %s", code, errOut)
|
|
}
|
|
if strings.Contains(out, "NET-OPEN") || strings.Contains(out, "PROC-WRITTEN") {
|
|
t.Fatalf("the sandbox must have no network and no writable /proc: %s", out)
|
|
}
|
|
if !strings.Contains(out, "net:") || !strings.Contains(out, "proc:") {
|
|
t.Fatalf("both probes must have run and been refused: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestOpTimeLimitAndGrantRuns(t *testing.T) {
|
|
requireSandbox(t)
|
|
dcn, priv := opTestBox(t)
|
|
enrollFactor(t, dcn)
|
|
root := filepath.Join(opTestRoot, fmt.Sprintf("g%d", time.Now().UnixNano()))
|
|
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
|
// Time limit: an op that sleeps past it is killed and refused.
|
|
slow := opFixture(root, "sleep 5")
|
|
slow[0].B64 = base64.StdEncoding.EncodeToString([]byte(strings.Replace(fmt.Sprintf(opTestManifest, root), "time_limit: 30", "time_limit: 1", 1)))
|
|
sha, _ := opDirDigest(slow)
|
|
run := opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root")
|
|
pair := signTermsPair(t, priv, run)
|
|
driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(slow))}, "", dcn)
|
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn); code != 126 || !strings.Contains(errOut, "time limit") {
|
|
t.Fatalf("past the time limit: %d %q", code, errOut)
|
|
}
|
|
// A grant (user): runs inside its bounds, spends uses on apply, refuses outside them.
|
|
// Its last line writes /dev/null, which a user-privilege op must be able
|
|
// to do (the sandbox's device nodes are 0666, past the umask).
|
|
ufiles := opFixture(root, "echo x > /dev/null")
|
|
ufiles[0].B64 = base64.StdEncoding.EncodeToString([]byte(strings.Replace(fmt.Sprintf(opTestManifest, root), "privilege: root", "privilege: user", 1)))
|
|
usha, _ := opDirDigest(ufiles)
|
|
gt := opTerm(opKindGrant, usha, "", `{"count":{"min":1,"max":2}}`, 2, time.Hour, "user")
|
|
purpose, _ := purposeForOpGrant(&opTerms{Server: gt["server"].(string), Op: "write-hello", SHA256: usha, Bounds: gt["bounds"].(string), Expires: gt["expires"].(string), Uses: 2})
|
|
raw, _ := b32decode(tSecret)
|
|
approval, err := stepupIssueApproval(dcn, hotp(raw, time.Now().Unix()/stepupPeriod), purpose)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gt["approval"] = approval
|
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, gt), bundleB64(ufiles))}, "", dcn); code != 0 {
|
|
t.Fatalf("grant sync: %s", errOut)
|
|
}
|
|
nonce := gt["nonce"].(string)
|
|
// The user-privilege op writes under a root the session user may not own:
|
|
// give it a folder that user can write.
|
|
_ = os.MkdirAll(root, 0o777)
|
|
_ = os.Chmod(root, 0o777) // past the umask
|
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeApply, `{"name":"x","count":3,"kind":"a"}`}}, "", dcn); code != 126 || !strings.Contains(errOut, "approved range") {
|
|
t.Fatalf("outside the bounds: %d %q", code, errOut)
|
|
}
|
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeApply, `{"name":"x","count":2,"kind":"a"}`}}, "", dcn); code != 0 {
|
|
t.Fatalf("inside the bounds: %d %s", code, errOut)
|
|
}
|
|
if _, rec, _ := opsHeldGrant(dcn, nonce); rec == nil || rec.UsesLeft != 1 {
|
|
t.Fatalf("one use spent: %+v", rec)
|
|
}
|
|
driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeApply, `{"name":"x","count":2,"kind":"a"}`}}, "", dcn)
|
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeApply, `{"name":"x","count":2,"kind":"a"}`}}, "", dcn); code != 126 || !strings.Contains(errOut, "no uses left") {
|
|
t.Fatalf("uses exhausted: %d %q", code, errOut)
|
|
}
|
|
if got, err := os.ReadFile(root + "/hello.txt"); err != nil || !strings.Contains(string(got), "count") {
|
|
t.Fatalf("the grant's applies landed: %v", err)
|
|
}
|
|
}
|
|
|
|
// ── refusals: a strike for the host, never for the box's own state ─────────
|
|
|
|
func TestOpRefusalsCountAsStrikesExceptTheBoxsOwnState(t *testing.T) {
|
|
requireSandbox(t)
|
|
dcn, priv := opTestBox(t)
|
|
root := filepath.Join(opTestRoot, fmt.Sprintf("s%d", time.Now().UnixNano()))
|
|
outside := root + "-outside"
|
|
t.Cleanup(func() { _ = os.RemoveAll(root); _ = os.RemoveAll(outside) })
|
|
files := opFixture(root, fmt.Sprintf(`mkdir -p %s; echo x > %s/leak`, outside, outside))
|
|
sha, _ := opDirDigest(files)
|
|
run := opTerm(opKindRun, sha, `{"name":"x","count":1,"kind":"a"}`, "", 1, time.Hour, "root")
|
|
pair := signTermsPair(t, priv, run)
|
|
driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn)
|
|
before := len(quarantineLoad().Pairing[tAnchor].refusalsNow())
|
|
if _, _, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn); code != 126 {
|
|
t.Fatal("refused")
|
|
}
|
|
if after := len(quarantineLoad().Pairing[tAnchor].refusalsNow()); after != before+1 {
|
|
t.Fatalf("a run outside its effects is a strike against the host: %d -> %d", before, after)
|
|
}
|
|
// The box's own state is never the host's strike.
|
|
if refusalCounts("op_dry_run", "op_dry_run refused: "+noSandboxHere+": no overlay") {
|
|
t.Fatal("a box that cannot sandbox is not the host's fault")
|
|
}
|
|
if !refusalCounts("op_apply", "op_apply refused: this op_run was applied already") {
|
|
t.Fatal("a second apply of the same op_run is the host's strike")
|
|
}
|
|
}
|
|
|
|
func (e *quarantineEntry) refusalsNow() []string {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.Refusals
|
|
}
|
|
|
|
func TestOpRemovingAFolderLandsExactlyThat(t *testing.T) {
|
|
requireSandbox(t)
|
|
dcn, priv := opTestBox(t)
|
|
root := filepath.Join(opTestRoot, fmt.Sprintf("d%d", time.Now().UnixNano()))
|
|
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
|
_ = os.MkdirAll(filepath.Join(root, "old", "deeper"), 0o755)
|
|
_ = os.WriteFile(filepath.Join(root, "old", "a.txt"), []byte("a\n"), 0o644)
|
|
_ = os.WriteFile(filepath.Join(root, "old", "deeper", "b.txt"), []byte("b\n"), 0o644)
|
|
_ = os.WriteFile(filepath.Join(root, "keep.txt"), []byte("keep\n"), 0o644)
|
|
files := opFixture(root, fmt.Sprintf(`rm -rf %s/old; rm -f %s/keep.txt`, root, root))
|
|
sha, _ := opDirDigest(files)
|
|
params := `{"name":"x","count":1,"kind":"a"}`
|
|
run := opTerm(opKindRun, sha, params, "", 1, time.Hour, "root")
|
|
pair := signTermsPair(t, priv, run)
|
|
driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn)
|
|
out, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn)
|
|
if code != 0 {
|
|
t.Fatalf("dry run: %s", errOut)
|
|
}
|
|
var rep opReport
|
|
_ = json.Unmarshal([]byte(out), &rep)
|
|
kinds := map[string]string{}
|
|
notes := map[string]string{}
|
|
for _, c := range rep.Changes {
|
|
kinds[c.Path] = c.Kind
|
|
notes[c.Path] = c.Note
|
|
}
|
|
if kinds[root+"/old"] != "removed_dir" || !strings.Contains(notes[root+"/old"], "3 entries") || kinds[root+"/keep.txt"] != "removed" || kinds[root+"/hello.txt"] != "created" {
|
|
t.Fatalf("the dry run names the removed folder (with what it holds), the removed file and the created one: %+v", rep.Changes)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "old", "a.txt")); err != nil {
|
|
t.Fatal("a dry run removes nothing")
|
|
}
|
|
pub, _ := deviceInit()
|
|
_ = pinDeviceKey(pub)
|
|
run["changes_sha256"] = rep.ChangesSHA256
|
|
purpose, _ := purposeForOpRun(&opTerms{Server: run["server"].(string), Op: "write-hello", SHA256: sha, Params: params, Expires: run["expires"].(string), Changes: rep.ChangesSHA256})
|
|
run["approval"], _ = deviceSign(purpose, time.Minute)
|
|
pair = signTermsPair(t, priv, run)
|
|
out, errOut, code = driveJob(t, tAnchor, mgmtJob{Op: "op_apply", Args: pair}, "", dcn)
|
|
if code != 0 {
|
|
t.Fatalf("apply: %s", errOut)
|
|
}
|
|
_ = json.Unmarshal([]byte(out), &rep)
|
|
if _, err := os.Stat(filepath.Join(root, "old")); err == nil {
|
|
t.Fatal("the folder the person saw removed is removed")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "keep.txt")); err == nil {
|
|
t.Fatal("the file the person saw removed is removed")
|
|
}
|
|
if got, _ := os.ReadFile(filepath.Join(root, "hello.txt")); !strings.HasPrefix(string(got), "hello ") {
|
|
t.Fatal("the created file landed")
|
|
}
|
|
if len(rep.Committed) != 3 || rep.VerifyError != "" {
|
|
t.Fatalf("three changes committed, verified: %+v", rep)
|
|
}
|
|
}
|
|
|
|
// The record is masked INSIDE the sandbox: an op cannot read the keys, and
|
|
// cannot remove or write the folder, whatever this side's path checks miss.
|
|
func TestOpSandboxMasksTheMachinesOwnRecord(t *testing.T) {
|
|
requireSandbox(t)
|
|
cfgHome := filepath.Join(opTestRoot, fmt.Sprintf("cfg%d", time.Now().UnixNano()))
|
|
t.Cleanup(func() { _ = os.RemoveAll(cfgHome) })
|
|
t.Setenv("XDG_CONFIG_HOME", cfgHome)
|
|
record := filepath.Join(cfgHome, "privatae-bastion")
|
|
_ = os.MkdirAll(record, 0o700)
|
|
_ = os.WriteFile(filepath.Join(record, "owner.json"), []byte("SECRET-KEY-MATERIAL"), 0o600)
|
|
root := filepath.Join(opTestRoot, fmt.Sprintf("m%d", time.Now().UnixNano()))
|
|
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
|
opDir := filepath.Join(opTestRoot, fmt.Sprintf("op%d", time.Now().UnixNano()))
|
|
t.Cleanup(func() { _ = os.RemoveAll(opDir) })
|
|
_ = os.MkdirAll(opDir, 0o755)
|
|
extra := fmt.Sprintf(`cat %s/owner.json; ls -A %s | wc -l; echo x > %s/planted 2>/dev/null && echo WROTE; rm -rf %s 2>/dev/null && echo RM-OK; test -e %s/owner.json && echo STILL-THERE-INSIDE; true`,
|
|
record, record, record, record, record)
|
|
for _, f := range opFixture(root, extra) {
|
|
raw, _ := base64.StdEncoding.DecodeString(f.B64)
|
|
_ = os.WriteFile(filepath.Join(opDir, f.Path), raw, os.FileMode(f.Mode))
|
|
}
|
|
m, err := parseOpManifest([]byte(fmt.Sprintf(opTestManifest, root)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = os.MkdirAll(opTestRoot, 0o755)
|
|
rep, err := opRunSandboxed(opDir, m, opModeDryRun, `{"name":"x","count":1,"kind":"a"}`)
|
|
if rep != nil {
|
|
defer rep.cleanup()
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("the op itself succeeds (its probes fail quietly): %v", err)
|
|
}
|
|
// Since the closed view (ops_view.go) the record is not in the op's view
|
|
// at all: nothing under it can be read or written, and `rm -rf` of a path
|
|
// that is not there succeeds without touching the real one (checked below).
|
|
if strings.Contains(rep.Output, "SECRET-KEY-MATERIAL") || strings.Contains(rep.Output, "WROTE") {
|
|
t.Fatalf("the record must be unreadable and unwritable inside the sandbox: %q", rep.Output)
|
|
}
|
|
if strings.Contains(rep.Output, "STILL-THERE-INSIDE") {
|
|
t.Fatalf("the mask hides the record's contents: %q", rep.Output)
|
|
}
|
|
for _, c := range rep.Changes {
|
|
if strings.HasPrefix(c.Path, cfgHome) {
|
|
t.Fatalf("nothing under the record is a change: %+v", c)
|
|
}
|
|
}
|
|
if raw, _ := os.ReadFile(filepath.Join(record, "owner.json")); string(raw) != "SECRET-KEY-MATERIAL" {
|
|
t.Fatal("the real record is untouched")
|
|
}
|
|
}
|