detached 0e6522c

ci_secrets.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"encoding/json"
8	"errors"
9	"fmt"
10	"slices"
11	"strings"
12	"sync/atomic"
13
14	"go.yaml.in/yaml/v3"
15
16	"bnema.dev/ermite-actions/expr"
17	"bnema.dev/ermite-actions/template"
18	"bnema.dev/ermite-actions/workflow"
19	"bnema.dev/ermite/internal/domain"
20	"bnema.dev/ermite/internal/ports"
21)
22
23// Which secrets a job receives (CI-3 plan D2, least disclosure).
24//
25// A job gets the secrets its workflow refers to and no others, so that a job
26// that never reads a secret never holds it. ermite walks every expression of
27// the YAML subtree of the job, and the workflow-level "env", with the
28// expression parser of ermite-actions: `secrets.NAME` and `secrets['NAME']`
29// name one secret; any other use of the context (a bare `secrets`,
30// `toJSON(secrets)`, `secrets[matrix.x]`, `secrets.*`) names all of them.
31// Missing one would only give the job an empty secret (a parity bug), never
32// a leaked one. The walk reads the YAML rather than the parsed model so that a
33// field the model does not list is still seen.
34
35// secretWalks counts the walks, for the tests of the cache.
36var secretWalks atomic.Int64
37
38// secretUse is the result of the walk.
39type secretUse struct {
40	// All is set when the job uses the secrets context in a way that does not
41	// name a secret.
42	All bool
43	// Names are the secrets named, upper-cased and sorted.
44	Names []string
45}
46
47// none reports whether the job refers to no secret.
48func (u secretUse) none() bool { return !u.All && len(u.Names) == 0 }
49
50// maxSecretWalkDepth bounds the YAML nesting followed (aliases included); a
51// workflow nested deeper is read as using every secret.
52const maxSecretWalkDepth = 200
53
54// secretsUsedBy walks the job and the workflow-level env of a workflow file.
55// A file that does not parse as YAML, or that nests too deep, uses all the
56// secrets: the safe reading for the run, whose job will fail on it anyway.
57func secretsUsedBy(workflowYAML, jobID string) secretUse {
58	secretWalks.Add(1)
59	var doc yaml.Node
60	if err := yaml.Unmarshal([]byte(workflowYAML), &doc); err != nil || len(doc.Content) == 0 {
61		return secretUse{All: true}
62	}
63	w := &secretWalk{names: map[string]bool{}}
64	root := w.deref(doc.Content[0])
65	if root.Kind != yaml.MappingNode {
66		return secretUse{All: true}
67	}
68	for i := 0; i+1 < len(root.Content); i += 2 {
69		key := root.Content[i].Value
70		switch key {
71		case "env":
72			w.node(root.Content[i+1], 0)
73		case "jobs":
74			jobs := w.deref(root.Content[i+1])
75			for j := 0; jobs.Kind == yaml.MappingNode && j+1 < len(jobs.Content); j += 2 {
76				if jobs.Content[j].Value == jobID {
77					w.node(jobs.Content[j+1], 0)
78				}
79			}
80		}
81	}
82	use := secretUse{All: w.all || w.tooDeep}
83	for n := range w.names {
84		use.Names = append(use.Names, n)
85	}
86	slices.Sort(use.Names)
87	return use
88}
89
90type secretWalk struct {
91	names   map[string]bool
92	all     bool
93	tooDeep bool
94}
95
96func (w *secretWalk) deref(n *yaml.Node) *yaml.Node {
97	for range 16 {
98		if n.Kind != yaml.AliasNode || n.Alias == nil {
99			return n
100		}
101		n = n.Alias
102	}
103	return n
104}
105
106// node visits every key and value below n.
107func (w *secretWalk) node(n *yaml.Node, depth int) {
108	if depth > maxSecretWalkDepth {
109		w.tooDeep = true
110		return
111	}
112	n = w.deref(n)
113	switch n.Kind {
114	case yaml.ScalarNode:
115		w.text(n.Value)
116	case yaml.SequenceNode, yaml.MappingNode, yaml.DocumentNode:
117		for _, c := range n.Content {
118			w.node(c, depth+1)
119		}
120	}
121}
122
123// text reads the ${{ }} expressions of a string.
124func (w *secretWalk) text(s string) {
125	for _, src := range expressionsIn(s) {
126		w.expression(src)
127	}
128}
129
130// expressionsIn returns the text inside each ${{ }} of s. A quote opens a
131// string literal inside the expression, where "}}" does not close it.
132func expressionsIn(s string) []string {
133	var out []string
134	for {
135		start := strings.Index(s, "${{")
136		if start < 0 {
137			return out
138		}
139		s = s[start+3:]
140		inStr, end := false, -1
141		for i := 0; i < len(s); i++ {
142			switch {
143			case s[i] == '\'':
144				inStr = !inStr // '' inside a literal toggles twice
145			case !inStr && s[i] == '}' && i+1 < len(s) && s[i+1] == '}':
146				end = i
147			}
148			if end >= 0 {
149				break
150			}
151		}
152		if end < 0 {
153			out = append(out, s) // unterminated: the workflow does not parse, read it all
154			return out
155		}
156		out = append(out, s[:end])
157		s = s[end+2:]
158	}
159}
160
161var (
162	exprContexts = []string{"github", "needs", "strategy", "matrix", "job", "jobs", "runner", "env", "vars", "secrets", "steps", "inputs"}
163	exprFuncs    = []expr.FuncInfo{
164		{Name: "hashFiles", MinArgs: 1, MaxArgs: -1}, {Name: "success"}, {Name: "failure"}, {Name: "always"}, {Name: "cancelled"},
165	}
166)
167
168func (w *secretWalk) expression(src string) {
169	node, err := expr.Parse(src, exprContexts, exprFuncs)
170	if err != nil || node == nil {
171		// Does not parse: the job will fail on it, but never miss a use.
172		if strings.Contains(strings.ToLower(src), "secrets") {
173			w.all = true
174		}
175		return
176	}
177	w.visit(node)
178}
179
180func isSecretsContext(n expr.Node) bool {
181	c, ok := n.(*expr.ContextAccess)
182	return ok && strings.EqualFold(c.Name.Lexeme, "secrets")
183}
184
185func (w *secretWalk) visit(n expr.Node) {
186	switch n := n.(type) {
187	case *expr.ContextAccess:
188		if isSecretsContext(n) {
189			w.all = true // a bare use of the context
190		}
191	case *expr.IndexAccess:
192		if isSecretsContext(n.Expr) {
193			if lit, ok := n.Index.(*expr.Literal); ok {
194				if s, ok := lit.Value.(expr.String); ok {
195					w.names[strings.ToUpper(string(s))] = true
196					return
197				}
198			}
199			w.all = true // secrets[x], secrets.*: not a name
200			w.visit(n.Index)
201			return
202		}
203		w.visit(n.Expr)
204		w.visit(n.Index)
205	case *expr.Unary:
206		w.visit(n.Expr)
207	case *expr.Binary:
208		w.visit(n.Left)
209		w.visit(n.Right)
210	case *expr.Logical:
211		for _, a := range n.Args {
212			w.visit(a)
213		}
214	case *expr.Grouping:
215		w.visit(n.Group)
216	case *expr.FunctionCall:
217		for _, a := range n.Args {
218			w.visit(a)
219		}
220	case *expr.Literal, *expr.Star, nil:
221	default:
222		panic(fmt.Sprintf("secretsUsedBy: unknown expression node %T", n)) // closed set of package expr
223	}
224}
225
226// --- variables, environments and secrets of a job ---
227
228// varsDictionary is the "vars" context for the merged variables.
229func varsDictionary(vars map[string]string) *expr.Dictionary {
230	d := expr.NewDictionary()
231	for _, k := range sortedKeys(vars) {
232		d.Add(k, expr.String(vars[k]))
233	}
234	return d
235}
236
237// varsContext is the "vars" context for what runs before a job has an
238// environment (run-name, matrix limits, if, strategy, runs-on, name): the
239// variables of the instance overridden by the repository's. It is empty when
240// the server resolves none. A failure is returned: running on partial
241// variables would give wrong results silently.
242func (c *CI) varsContext(ctx context.Context, repo domain.RepoName) (*expr.Dictionary, error) {
243	if c.cfg.Settings == nil {
244		return expr.NewDictionary(), nil
245	}
246	got, err := c.cfg.Settings.Resolve(ctx, ports.JobSettingsRequest{Repo: repo})
247	if err != nil {
248		return nil, fmt.Errorf("read the variables: %w", err)
249	}
250	return varsDictionary(got.Vars), nil
251}
252
253// environmentOf evaluates the "environment:" of a job (a name, an expression or
254// a mapping with a name and an url, which is ignored) and returns the name, ""
255// for a job without one.
256func environmentOf(ctx context.Context, wj *workflow.Job, env template.Env) (string, error) {
257	if wj.Environment == nil {
258		return "", nil
259	}
260	tok := wj.Environment
261	if m, ok := tok.(*template.Mapping); ok {
262		tok = m.Find("name")
263		if tok == nil {
264			return "", fmt.Errorf("job '%s': environment needs a name", wj.ID)
265		}
266	}
267	name, err := template.EvaluateString(ctx, tok, env)
268	if err != nil {
269		return "", err
270	}
271	// Validated as evaluated, never trimmed: the name a workflow gives is the
272	// name the API and the CLI accept.
273	name, err = domain.ParseEnvironmentName(name)
274	if err != nil {
275		var te *domain.TextError
276		if errors.As(err, &te) {
277			return "", fmt.Errorf("job '%s': the environment name %q %s", wj.ID, name, te.Reason)
278		}
279		return "", fmt.Errorf("job '%s': invalid environment name %q", wj.ID, name)
280	}
281	return name, nil
282}
283
284// settingsOf resolves the variables and secrets of a job at its assignment:
285// the variables always, the secrets only for a trusted run and only the ones
286// the job's workflow refers to. The result is for the runner and nowhere else.
287func (c *CI) settingsOf(ctx context.Context, rs *runState, j domain.CIJob) (ports.JobSettings, error) {
288	if c.cfg.Settings == nil {
289		return ports.JobSettings{}, nil
290	}
291	req := ports.JobSettingsRequest{
292		Repo: rs.run.Repo, Environment: j.Environment,
293		// An environment is created on first use, by a run of the owner: a run
294		// that is not trusted does not add environments to the repository.
295		CreateEnvironment: j.Environment != "" && rs.run.Trusted,
296	}
297	var inner secretUse
298	scoped := rs.scopeOf(j) != nil
299	if rs.run.Trusted {
300		var use secretUse
301		if scoped {
302			// A job of a called workflow gets the secrets its caller passes
303			// it, under the names the called workflow gives them.
304			use, inner = rs.scopedSecretUse(j)
305		} else {
306			use = c.flows.secretUse(rs.run.ID, rs.src.WorkflowYAML, j.Key)
307		}
308		req.WantSecrets, req.AllSecrets, req.SecretNames = !use.none(), use.All, use.Names
309	}
310	got, err := c.cfg.Settings.Resolve(ctx, req)
311	if err != nil {
312		return ports.JobSettings{}, &settingsError{err}
313	}
314	if scoped && rs.run.Trusted {
315		// Also when no stored secret resolved: a mapping can give a literal
316		// value. A run that is not trusted gets nothing.
317		got.Secrets = rs.mapSecrets(ctx, j, got.Secrets, inner)
318	}
319	return got, nil
320}
321
322// settingsError is a failure to read the variables and secrets of a job. It is
323// its own type because the callers that skip a job gone or taken meanwhile
324// (errNoJob, ErrNotFound, ErrConflict) must never skip on it: an error of the
325// settings, even one that wraps ErrConflict (the creation of an environment
326// ran out of retries), is the caller's to see, and changes nothing.
327type settingsError struct{ err error }
328
329func (e *settingsError) Error() string {
330	return "read the variables and secrets of the job: " + e.err.Error()
331}
332func (e *settingsError) Unwrap() error { return e.err }
333
334// isSettingsError reports whether err comes from reading the settings of a job.
335func isSettingsError(err error) bool {
336	var se *settingsError
337	return errors.As(err, &se)
338}
339
340// withVars returns the contexts JSON of a job with its "vars" replaced by the
341// current variables, so that a variable changed while the job waited is seen.
342// The contexts hold no secret.
343func withVars(contexts string, vars map[string]string) string {
344	var m map[string]json.RawMessage
345	if err := json.Unmarshal([]byte(contexts), &m); err != nil {
346		return contexts
347	}
348	b, err := json.Marshal(vars)
349	if err != nil {
350		return contexts
351	}
352	m["vars"] = b
353	out, err := json.Marshal(m)
354	if err != nil {
355		return contexts
356	}
357	return string(out)
358}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close