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}