Files
privatae-bastion/client-go/ops_action_test.go
T
Privatae 8822d5df85
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m51s
ci / fuzz (push) Successful in 2m55s
bastion 0.55.3
2026-10-25 23:16:45 +00:00

309 lines
17 KiB
Go

//go:build !windows
package main
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// opActionManifest is a valid action op; the tests change one thing at a time.
const opActionManifest = `name: web-roll
version: 1.0.0
summary: Move the web tree forward and restart it.
kind: action
target: machine
privilege: root
interpreter: /bin/sh
time_limit: 30
params:
commit: {type: string, max_len: 64}
digest: {type: string, max_len: 71}
count: {type: integer, min: 1, max: 3}
destinations: ["forge.example.com:443", "[2001:db8::1]:5000"]
credentials: [forge-read, "registry:acme"]
verbs:
- {verb: tree.checkout, path: /opt/acme, repo: "https://forge.example.com/acme/app.git", commit: "{{commit}}", credential: forge-read}
- {verb: unit.restart, unit: acme-web.service}
- {verb: k8s.set_image, namespace: ds, kind: deployment, name: web, container: web, image: forge.example.com/acme/web, digest: "{{digest}}", repo: "https://forge.example.com/acme/app.git"}
- {verb: logs.check, unit: acme-web.service, must_not: "Traceback", lines: 200}
modes: [dry_run, apply, verify]
undo: none
`
func TestActionManifestParses(t *testing.T) {
setupBox(t)
m, err := parseOpManifest([]byte(opActionManifest))
if err != nil {
t.Fatal(err)
}
if !m.isAction() || len(m.Verbs) != 4 || len(m.Credentials) != 2 {
t.Fatalf("parsed: %+v", m)
}
lines, err := opResolveVerbs(m, map[string]any{"commit": strings.Repeat("a", 40), "digest": "sha256:" + strings.Repeat("b", 64)})
if err != nil {
t.Fatal(err)
}
if lines[0].Args["commit"] != strings.Repeat("a", 40) || lines[2].Args["digest"] != "sha256:"+strings.Repeat("b", 64) {
t.Fatalf("the params land in the lines that name them: %+v", lines)
}
if lines[2].target() != "ds/deployment/web:web" {
t.Fatalf("target: %s", lines[2].target())
}
// A param resolved to something the argument may not be is refused.
if _, err := opResolveVerbs(m, map[string]any{"commit": "main", "digest": "sha256:" + strings.Repeat("b", 64)}); err == nil ||
!strings.Contains(err.Error(), "never a branch") {
t.Fatalf("a branch name is not a commit: %v", err)
}
if opCredEnv("registry:acme") != "PRIVATAE_CRED_REGISTRY_ACME" {
t.Fatal(opCredEnv("registry:acme"))
}
}
// Every way a manifest could reach past what its verbs and destinations
// declare is refused when the op arrives, with the sentence.
func TestActionManifestRefusesWhatIsNotDeclaredOrNotBounded(t *testing.T) {
setupBox(t)
sub := func(old, new string) string {
if !strings.Contains(opActionManifest, old) {
t.Fatalf("fixture has no %q", old)
}
return strings.Replace(opActionManifest, old, new, 1)
}
verbs := func(v string) string {
i := strings.Index(opActionManifest, "verbs:\n")
j := strings.Index(opActionManifest, "modes:")
return opActionManifest[:i] + "verbs:\n" + v + opActionManifest[j:]
}
for name, c := range map[string]struct{ man, want string }{
"an unknown verb": {verbs(" - {verb: shell.run, cmd: id}\n"), "not a verb"},
"an argument the verb does not take": {verbs(" - {verb: unit.restart, unit: a.service, signal: KILL}\n"), "takes no argument"},
"a workload named by a param": {sub("name: web,", `name: "{{commit}}",`), "fixed in the manifest"},
"a container named by a param": {sub("container: web,", `container: "{{commit}}",`), "fixed in the manifest"},
"a unit named by a param": {sub("unit: acme-web.service}", `unit: "{{commit}}"}`), "fixed in the manifest"},
"a tree path named by a param": {sub("path: /opt/acme,", `path: "{{commit}}",`), "fixed in the manifest"},
"a param that is not a string": {sub(`commit: "{{commit}}"`, `commit: "{{count}}"`), "not a string param"},
"a param that does not exist": {sub(`commit: "{{commit}}"`, `commit: "{{nope}}"`), "not a string param"},
"a credential the op does not declare": {sub("credential: forge-read}", "credential: other}"), "does not declare"},
"verbs under privilege user": {sub("privilege: root", "privilege: user"), "privilege root exactly when"},
"root with no verbs": {verbs(""), "privilege root exactly when"},
"file effects": {sub("kind: action", "kind: action\neffects:\n files: [/srv/x/]"), "effects must be empty"},
"an undo": {sub("modes: [dry_run, apply, verify]\nundo: none", "modes: [dry_run, apply, verify, undo]\nundo: run"), "undo: none"},
"a destination on localhost": {sub(`"forge.example.com:443"`, `"localhost:443"`), "never localhost"},
"a destination on loopback": {sub(`"forge.example.com:443"`, `"127.0.0.1:443"`), "loopback"},
"a destination with no port": {sub(`"forge.example.com:443"`, `"forge.example.com"`), "host:port"},
"the metadata service": {sub(`"forge.example.com:443"`, `"169.254.169.254:80"`), "link-local"},
"a link-local IPv6 address": {sub(`"forge.example.com:443"`, `"[fe80::1]:80"`), "link-local"},
"a metadata service outside link-local": {sub(`"forge.example.com:443"`, `"100.100.100.200:80"`), "metadata"},
"AWS's IPv6 metadata service": {sub(`"forge.example.com:443"`, `"[fd00:ec2::254]:80"`), "metadata"},
"the metadata service through NAT64": {sub(`"forge.example.com:443"`, `"[64:ff9b::a9fe:a9fe]:80"`), "link-local"},
"loopback through IPv4-mapped IPv6": {sub(`"forge.example.com:443"`, `"[::ffff:127.0.0.1]:80"`), "loopback"},
"a roll with no repository": {sub(`, repo: "https://forge.example.com/acme/app.git"}`, "}"), "needs repo"},
"the bastion's own unit": {sub("unit: acme-web.service}", "unit: privatae-bastion.service}"), "never restarts itself"},
"a tree on a credential store": {sub("path: /opt/acme,", "path: /etc/ssh,"), "sensitive"},
"a tree at /": {sub("path: /opt/acme,", "path: /,"), "not /"},
"an image with a tag": {sub("image: forge.example.com/acme/web,", "image: \"forge.example.com/acme/web:latest\","), "no tag or digest"},
"a kind the verb does not roll": {sub("kind: deployment,", "kind: cronjob,"), "deployment, statefulset or daemonset"},
"a literal commit that is a branch": {sub(`commit: "{{commit}}"`, `commit: main`), "never a branch"},
"a repo that is not https": {sub(`repo: "https://forge.example.com/acme/app.git"`, `repo: "ssh://forge.example.com/acme/app.git"`), "https"},
"a log check on two things": {sub("must_not: \"Traceback\"", "namespace: ds, kind: deployment, name: b, must_not: \"Traceback\""), "either one unit or one workload"},
"a log check with nothing to look for": {sub("must_not: \"Traceback\", ", ""), "must, must_not or both"},
"a tree moved twice": {sub(" - {verb: tree.checkout, path: /opt/acme, repo: \"https://forge.example.com/acme/app.git\", commit: \"{{commit}}\", credential: forge-read}\n",
" - {verb: tree.checkout, path: /opt/acme, repo: \"https://forge.example.com/acme/app.git\", commit: \"{{commit}}\", credential: forge-read}\n - {verb: tree.checkout, path: /opt/acme, repo: \"https://forge.example.com/acme/app.git\", commit: \"{{commit}}\", credential: forge-read}\n"), "moved twice"},
"a container rolled twice": {sub(" - {verb: k8s.set_image, namespace: ds, kind: deployment, name: web, container: web, image: forge.example.com/acme/web, digest: \"{{digest}}\", repo: \"https://forge.example.com/acme/app.git\"}\n",
" - {verb: k8s.set_image, namespace: ds, kind: deployment, name: web, container: web, image: forge.example.com/acme/web, digest: \"{{digest}}\", repo: \"https://forge.example.com/acme/app.git\"}\n - {verb: k8s.set_image, namespace: ds, kind: deployment, name: web, container: web, image: forge.example.com/acme/web, digest: \"{{digest}}\", repo: \"https://forge.example.com/acme/app.git\"}\n"), "moved twice"},
"a log check with a bad pattern": {sub(`must_not: "Traceback"`, `must_not: "("`), "not a valid pattern"},
"a log check reading too much": {sub("lines: 200", "lines: 999999"), "1..2000"},
"two credentials in one variable": {sub(`credentials: [forge-read, "registry:acme"]`, `credentials: [forge-read, "registry:acme", "registry-acme"]`), "same variable"},
"a verb missing its argument": {verbs(" - {verb: unit.restart}\n"), "needs unit"},
"a read of a credential store": {sub("kind: action", "kind: action\nreads: [/root/.docker/config.json]"), "sensitive"},
} {
_, err := parseOpManifest([]byte(c.man))
if err == nil || (c.want != "" && !strings.Contains(err.Error(), c.want)) {
t.Errorf("%s: want a refusal saying %q, got %v", name, c.want, err)
}
}
// A files op carrying an action op's fields is refused, not read as one.
files := strings.Replace(opTestManifest, "modes:", "verbs:\n - {verb: unit.restart, unit: a.service}\nmodes:", 1)
if _, err := parseOpManifest([]byte(strings.Replace(files, "%s/", "/srv/privatae-optest/x/", 1))); err == nil || !strings.Contains(err.Error(), "belong to an action op") {
t.Fatalf("a files op with verbs: %v", err)
}
}
func TestSealedCredentialsOpenOnlyForThisRunAndThisOp(t *testing.T) {
setupBox(t)
m, err := parseOpManifest([]byte(opActionManifest))
if err != nil {
t.Fatal(err)
}
pub, _, err := opEncKey()
if err != nil {
t.Fatal(err)
}
exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
seal := func(name, value, nonce, expires string) string {
b, err := opSealCredFor(pub, opSealedCred{Name: name, Value: value, Nonce: nonce, Expires: expires})
if err != nil {
t.Fatal(err)
}
return b
}
nonce := "n-0123456789abcdef"
good := []string{seal("forge-read", "tok-1", nonce, exp), seal("registry:acme", "tok-2", nonce, exp)}
got, err := opOpenCreds(good, m, nonce, false)
if err != nil || got["forge-read"] != "tok-1" || got["registry:acme"] != "tok-2" {
t.Fatalf("both open: %v %v", got, err)
}
other, _, _ := opEncKeyPairForTest()
for name, c := range map[string]struct {
blobs []string
want string
}{
"sealed for another run": {[]string{seal("forge-read", "t", "n-other-run-000000", exp), good[1]}, "another run"},
"a name the op does not declare": {[]string{good[0], seal("root-ssh", "t", nonce, exp)}, "not one this op declares"},
"expired": {[]string{seal("forge-read", "t", nonce, time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)), good[1]}, "expired"},
"one missing": {good[:1], "was not handed it"},
"twice": {[]string{good[0], good[0]}, "arrived twice"},
"too many": {append(append([]string{}, good...), good[0]), "more credentials"},
"sealed to another machine": {[]string{sealTo(t, other, opSealedCred{Name: "forge-read", Value: "t", Nonce: nonce, Expires: exp}), good[1]}, "does not open"},
"tampered": {[]string{flipB64(good[0]), good[1]}, "does not open"},
} {
if _, err := opOpenCreds(c.blobs, m, nonce, false); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: want %q, got %v", name, c.want, err)
}
}
if _, err := opOpenCreds(good, m, nonce, true); err == nil || !strings.Contains(err.Error(), "never handed") {
t.Fatalf("a dry run is handed nothing: %v", err)
}
}
func opEncKeyPairForTest() (*[32]byte, *[32]byte, error) {
d := os.Getenv("XDG_CONFIG_HOME")
os.Setenv("XDG_CONFIG_HOME", d+"-other")
defer os.Setenv("XDG_CONFIG_HOME", d)
return opEncKey()
}
func sealTo(t *testing.T, pub *[32]byte, c opSealedCred) string {
b, err := opSealCredFor(pub, c)
if err != nil {
t.Fatal(err)
}
return b
}
func flipB64(s string) string {
raw, _ := base64.StdEncoding.DecodeString(s)
raw[len(raw)-1] ^= 1
return base64.StdEncoding.EncodeToString(raw)
}
// The encryption key is stable, travels signed by the machine's own key, and
// a key file that does not read is never quietly replaced.
func TestEncryptionKeyIsStableSignedAndNeverSilentlyReplaced(t *testing.T) {
setupBox(t)
c1, c2 := opEncKeyClaim(), opEncKeyClaim()
if c1 == nil || c1["public_key"] != c2["public_key"] {
t.Fatalf("stable: %v %v", c1, c2)
}
mk, _ := base64.StdEncoding.DecodeString(c1["machine_key"].(string))
sig, _ := base64.StdEncoding.DecodeString(c1["signature"].(string))
if !ed25519.Verify(mk, opEncKeySigningInput(c1["public_key"].(string)), sig) {
t.Fatal("the claim verifies under the machine's key")
}
if ed25519.Verify(mk, []byte(c1["public_key"].(string)), sig) {
t.Fatal("the signature is over the tagged input only")
}
d, _ := configDir()
_ = os.WriteFile(filepath.Join(d, opEncKeyFile), []byte("{broken"), 0o600)
if _, _, err := opEncKey(); err == nil {
t.Fatal("a broken key file is refused, not replaced")
}
}
func TestReceiptsAreThisMachinesAndBindTheCommit(t *testing.T) {
setupBox(t)
digest := "sha256:" + strings.Repeat("c", 64)
images := []opResultImage{{Image: "forge.example.com/acme/web", Digest: digest,
Source: "https://forge.example.com/acme/app.git", Commit: strings.Repeat("d", 40)}}
rs, err := opIssueReceipts("run1", "web-build", strings.Repeat("e", 64), `{"commit":"x"}`, images)
if err != nil || len(rs) != 1 {
t.Fatal(rs, err)
}
r, err := opReceiptFor(digest)
if err != nil || r == nil || r.Commit != strings.Repeat("d", 40) || r.Op != "web-build" {
t.Fatalf("held: %+v %v", r, err)
}
// A receipt alone is never a record of what is live: only a roll is.
if _, _, _, ok := opRolledCommit(digest); ok {
t.Fatal("a receipt made the digest look rolled")
}
// The first receipt for a digest stands: the same again is fine, another
// commit for it is refused and changes nothing.
if _, err := opIssueReceipts("run2", "web-build", strings.Repeat("e", 64), `{}`, images); err != nil {
t.Fatalf("the same receipt again: %v", err)
}
conflict := []opResultImage{{Image: images[0].Image, Digest: digest, Source: images[0].Source, Commit: strings.Repeat("0", 40)}}
if _, err := opIssueReceipts("run3", "web-build", strings.Repeat("e", 64), `{}`, conflict); err == nil || !strings.Contains(err.Error(), "already holds a receipt") {
t.Fatalf("a conflicting receipt: %v", err)
}
if r, _ := opReceiptFor(digest); r == nil || r.Commit != strings.Repeat("d", 40) {
t.Fatal("the first receipt was rewritten")
}
if r, _ := opReceiptFor("sha256:" + strings.Repeat("0", 64)); r != nil {
t.Fatal("no receipt for a digest never built here")
}
// A receipt whose bytes changed does not verify.
var raw map[string]any
body, _ := base64.StdEncoding.DecodeString(rs[0].Payload)
_ = json.Unmarshal(body, &raw)
raw["commit"] = strings.Repeat("f", 40)
forged, _ := json.Marshal(raw)
if _, err := opVerifyReceipt(opSignedReceipt{PayloadType: opReceiptPayloadType, Payload: base64.StdEncoding.EncodeToString(forged), Signature: rs[0].Signature}); err == nil {
t.Fatal("a changed receipt is refused")
}
// Nor one relabelled as another type.
if _, err := opVerifyReceipt(opSignedReceipt{PayloadType: "application/json", Payload: rs[0].Payload, Signature: rs[0].Signature}); err == nil {
t.Fatal("the payload type is part of what is signed")
}
// Nor one another machine signed.
pub, priv, _ := ed25519.GenerateKey(nil)
raw["key"] = base64.StdEncoding.EncodeToString(pub)
other, _ := json.Marshal(raw)
s := opSignedReceipt{PayloadType: opReceiptPayloadType, Payload: base64.StdEncoding.EncodeToString(other),
Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, opReceiptSigningInput(other)))}
if _, err := opVerifyReceipt(s); err == nil || !strings.Contains(err.Error(), "another machine") {
t.Fatalf("a foreign receipt is refused here: %v", err)
}
// What a roll recorded is known too.
d2 := "sha256:" + strings.Repeat("9", 64)
_ = opRecordRoll(d2, opRollRec{Image: "forge.example.com/acme/web", Source: "s", Commit: strings.Repeat("8", 40)})
if _, _, c, ok := opRolledCommit(d2); !ok || c != strings.Repeat("8", 40) {
t.Fatal("a rolled digest's commit is known")
}
}
func TestProgramResultIsJudged(t *testing.T) {
good := `{"images":[{"image":"forge.example.com/acme/web","digest":"sha256:` + strings.Repeat("a", 64) +
`","source":"https://forge.example.com/acme/app.git","commit":"` + strings.Repeat("b", 40) + `"}]}`
if im, err := opParseResult([]byte(good)); err != nil || len(im) != 1 {
t.Fatal(im, err)
}
for _, bad := range []string{
`{"images":[{"image":"forge.example.com/acme/web:latest","digest":"sha256:` + strings.Repeat("a", 64) + `","source":"https://forge.example.com/a/b.git","commit":"` + strings.Repeat("b", 40) + `"}]}`,
`{"images":[{"image":"forge.example.com/acme/web","digest":"sha256:short","source":"https://forge.example.com/a/b.git","commit":"` + strings.Repeat("b", 40) + `"}]}`,
`{"images":[{"image":"forge.example.com/acme/web","digest":"sha256:` + strings.Repeat("a", 64) + `","source":"file:///etc","commit":"` + strings.Repeat("b", 40) + `"}]}`,
`not json`,
} {
if _, err := opParseResult([]byte(bad)); err == nil {
t.Errorf("refused: %s", bad)
}
}
}