//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/work/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) } } }