tenseleyflow/shithub / 2b63ffd

Browse files

actions/runner: snapshot secret masks at claim

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
2b63ffdab518650227485654210a0fcf91a28e1d
Parents
841bff9
Tree
9ba32f8

23 changed files

StatusFile+-
M docs/internal/actions-runner-api.md 9 5
M docs/internal/actions-schema.md 35 23
A internal/actions/queries/workflow_job_secret_masks.sql 14 0
M internal/actions/sqlc/models.go 7 0
M internal/actions/sqlc/querier.go 3 0
A internal/actions/sqlc/workflow_job_secret_masks.sql.go 50 0
M internal/admin/sqlc/models.go 7 0
M internal/auth/policy/sqlc/models.go 7 0
M internal/checks/sqlc/models.go 7 0
M internal/issues/sqlc/models.go 7 0
M internal/meta/sqlc/models.go 7 0
A internal/migrationsfs/migrations/0055_workflow_job_secret_masks.sql 16 0
M internal/notif/sqlc/models.go 7 0
M internal/orgs/sqlc/models.go 7 0
M internal/pulls/sqlc/models.go 7 0
M internal/ratelimit/sqlc/models.go 7 0
M internal/repos/sqlc/models.go 7 0
M internal/social/sqlc/models.go 7 0
M internal/users/sqlc/models.go 7 0
M internal/web/handlers/api/runners.go 116 32
M internal/web/handlers/api/runners_test.go 6 0
M internal/webhook/sqlc/models.go 7 0
M internal/worker/sqlc/models.go 7 0
docs/internal/actions-runner-api.mdmodified
@@ -63,7 +63,11 @@ Returns 204 when no matching job is claimable. Returns 200 with
6363
 enforced server-side by counting current `workflow_jobs.status =
6464
 'running'` rows for the runner while holding a row lock on the runner.
6565
 The job payload includes resolved `secrets` and `mask_values`; repo
66
-secrets shadow org secrets with the same name.
66
+secrets shadow org secrets with the same name. The server also stores
67
+an encrypted claim-time copy of the mask values on
68
+`workflow_job_secret_masks` so later log uploads are scrubbed against
69
+the secrets that were actually handed to the runner, even if an
70
+operator rotates or deletes a secret mid-job.
6771
 
6872
 `POST /api/v1/jobs/{id}/logs`
6973
 
@@ -77,10 +81,10 @@ Auth: job JWT. Body:
7781
 first step in the job receives the chunk. Chunks are base64-decoded,
7882
 capped at 512 KiB raw, and appended to `workflow_step_log_chunks`.
7983
 Duplicate `(step_id, seq)` inserts are accepted as idempotent retries.
80
-Before append, the API re-scrubs exact secret values from the runner
81
-claim's visible secret set. It also reprocesses any possible secret
82
-prefix carried at the end of the prior chunk, so a runner cannot leak a
83
-secret by splitting it across two log calls.
84
+Before append, the API re-scrubs exact secret values from the job's
85
+claim-time mask snapshot. It also reprocesses any possible secret prefix
86
+carried at the end of the prior chunk, so a runner cannot leak a secret
87
+by splitting it across two log calls.
8488
 
8589
 `POST /api/v1/jobs/{id}/steps/{step_id}/status`
8690
 
docs/internal/actions-schema.mdmodified
@@ -12,9 +12,9 @@ without churning under them.
1212
 
1313
 ## SQL schema
1414
 
15
-Actions migrations currently span 0042–0051 and 0053. Migration 0052 belongs to
16
-the repo source-remotes feature and was already deployed before the runner JWT
17
-replay table landed.
15
+Actions migrations currently span 0042–0051, 0053, and 0055. Migration
16
+0052 belongs to the repo source-remotes feature and 0054 belongs to push
17
+event protocol tracking.
1818
 
1919
 | #     | Table                       | Purpose                                                       |
2020
 | ----- | --------------------------- | ------------------------------------------------------------- |
@@ -29,6 +29,7 @@ replay table landed.
2929
 | 0050  | `workflow_steps.step_with`  | Parsed `with:` inputs for magic `uses:` aliases               |
3030
 | 0051  | `workflow_runs.trigger_event_id` | Trigger idempotency for retries/admin replays            |
3131
 | 0053  | `runner_jwt_used`           | Single-use replay gate for runner job JWTs                    |
32
+| 0055  | `workflow_job_secret_masks` | Encrypted claim-time log mask snapshots per job               |
3233
 
3334
 A few load-bearing choices, called out so they're easy to spot in a
3435
 later schema diff:
@@ -66,6 +67,11 @@ later schema diff:
6667
   and the API returns 401. JWTs are HMAC-SHA256 and use an HKDF
6768
   subkey derived from `auth.totp_key_b64` with label
6869
   `actions-runner-jwt-v1`.
70
+- **`workflow_job_secret_masks`** — one encrypted JSON array of exact
71
+  secret values per claimed job. It snapshots the log scrub set at
72
+  claim time, preventing a rotated or deleted secret from disappearing
73
+  from server-side masking while the old value is still in a runner's
74
+  job payload.
6975
 
7076
 The `version` and `run_index` patterns are the two pieces I'd point
7177
 out to a future maintainer first. Both are cheap to add now and
@@ -176,7 +182,7 @@ This is a deliberate decision recorded in the campaign plan.
176182
 
177183
 | Namespace        | Source            | Tainted?                    |
178184
 | ---------------- | ----------------- | --------------------------- |
179
-| `secrets.X`      | workflow_secrets  | no (operator-controlled)    |
185
+| `secrets.X`      | workflow_secrets  | no, but sensitive           |
180186
 | `vars.X`         | actions_variables | no (operator-controlled)    |
181187
 | `env.X`          | workflow file     | no (workflow author's text) |
182188
 | `shithub.run_id` | dispatch context  | no                          |
@@ -250,17 +256,22 @@ Propagation rules:
250256
 
251257
 - Reading `shithub.event.X` → `Tainted: true` (always, including
252258
   missing-path null results).
253
-- Reading any other namespace → `Tainted: false`, except `env.X`
254
-  preserves the taint of the resolved env value. This closes the
255
-  escape where an event-derived value is first assigned to env and
256
-  then interpolated through `${{ env.X }}`.
257
-- Binary op (`==`, `!=`, `&&`, `||`) → tainted if either operand is.
258
-- Unary op (`!`) → tainted iff its operand is.
259
-- Function call (`contains`, `startsWith`, `endsWith`) → tainted
260
-  if any argument is.
261
-
262
-The runner consumes `Tainted` and refuses to interpolate tainted
263
-values into shell strings. Instead, tainted values are bound to
259
+- Reading `secrets.X` → `Sensitive: true`. Secrets are operator-
260
+  controlled, so they are not tainted, but they must not appear in
261
+  shell source strings or Docker argv.
262
+- Reading any other namespace → `Tainted: false` and
263
+  `Sensitive: false`, except `env.X` preserves both flags of the
264
+  resolved env value. This closes the escape where an event-derived or
265
+  secret-derived value is first assigned to env and then interpolated
266
+  through `${{ env.X }}`.
267
+- Binary op (`==`, `!=`, `&&`, `||`) → tainted or sensitive if either
268
+  operand is.
269
+- Unary op (`!`) → tainted/sensitive iff its operand is.
270
+- Function call (`contains`, `startsWith`, `endsWith`) → tainted or
271
+  sensitive if any argument is.
272
+
273
+The runner consumes `Tainted` and `Sensitive` and refuses to interpolate
274
+either class into shell strings. Instead, those values are bound to
264275
 runner-owned `SHITHUB_INPUT_xx` envvars and the shell source only
265276
 references those placeholders. The author writes:
266277
 
@@ -275,9 +286,9 @@ SHITHUB_INPUT_0="$user_pr_title" exec sh -c 'echo "PR title was: $SHITHUB_INPUT_
275286
 ```
276287
 
277288
 …where `$user_pr_title` is set via Go's `cmd.Env`, never inserted into
278
-the shell source string. Backticks, `$()`, `;`, `&&` — none of those
279
-work as command-injection vectors when the value reaches the shell as
280
-environment data instead of syntax.
289
+the shell source string or Docker CLI argv. Backticks, `$()`, `;`,
290
+`&&` — none of those work as command-injection vectors when the value
291
+reaches the shell as environment data instead of syntax.
281292
 
282293
 The shared renderer lives in `internal/runner/exec`, so future engines
283294
 consume the same injection boundary instead of reimplementing it. The
@@ -295,11 +306,12 @@ Runner log chunks pass through `internal/runner/scrub` before they are
295306
 posted to the API. It masks exact secret values and preserves enough
296307
 tail bytes between chunks to catch a secret split across chunk
297308
 boundaries. S41e wires resolved workflow secrets into the runner claim
298
-payload and mask set, then applies the same exact-value scrub again in
299
-the runner API before persisting chunks. The server path also carries a
300
-possible secret-prefix tail from the prior persisted chunk, so a runner
301
-that bypasses client-side scrubbing cannot leak a secret by splitting
302
-it across adjacent log POSTs.
309
+payload and mask set, snapshots that mask set encrypted on the job, then
310
+applies the same exact-value scrub again in the runner API before
311
+persisting chunks. The server path also carries a possible secret-prefix
312
+tail from the prior persisted chunk, so a runner that bypasses
313
+client-side scrubbing cannot leak a secret by splitting it across
314
+adjacent log POSTs.
303315
 
304316
 ## `shithub.event` payload schema (v1)
305317
 
internal/actions/queries/workflow_job_secret_masks.sqladded
@@ -0,0 +1,14 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+-- name: UpsertWorkflowJobSecretMask :exec
4
+INSERT INTO workflow_job_secret_masks (job_id, ciphertext, nonce)
5
+VALUES ($1, $2, $3)
6
+ON CONFLICT (job_id) DO UPDATE
7
+SET ciphertext = EXCLUDED.ciphertext,
8
+    nonce      = EXCLUDED.nonce,
9
+    created_at = now();
10
+
11
+-- name: GetWorkflowJobSecretMask :one
12
+SELECT job_id, ciphertext, nonce, created_at
13
+FROM workflow_job_secret_masks
14
+WHERE job_id = $1;
internal/actions/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/actions/sqlc/querier.gomodified
@@ -45,6 +45,7 @@ type Querier interface {
4545
 	GetStepLogChunkBefore(ctx context.Context, db DBTX, arg GetStepLogChunkBeforeParams) (WorkflowStepLogChunk, error)
4646
 	GetStepLogChunkByStepSeq(ctx context.Context, db DBTX, arg GetStepLogChunkByStepSeqParams) (WorkflowStepLogChunk, error)
4747
 	GetWorkflowJobByID(ctx context.Context, db DBTX, id int64) (WorkflowJob, error)
48
+	GetWorkflowJobSecretMask(ctx context.Context, db DBTX, jobID int64) (WorkflowJobSecretMask, error)
4849
 	GetWorkflowRunByID(ctx context.Context, db DBTX, id int64) (WorkflowRun, error)
4950
 	GetWorkflowStepByID(ctx context.Context, db DBTX, id int64) (WorkflowStep, error)
5051
 	HeartbeatRunner(ctx context.Context, db DBTX, arg HeartbeatRunnerParams) (WorkflowRunner, error)
@@ -97,6 +98,8 @@ type Querier interface {
9798
 	UpsertRepoSecret(ctx context.Context, db DBTX, arg UpsertRepoSecretParams) (WorkflowSecret, error)
9899
 	// SPDX-License-Identifier: AGPL-3.0-or-later
99100
 	UpsertRepoVariable(ctx context.Context, db DBTX, arg UpsertRepoVariableParams) (ActionsVariable, error)
101
+	// SPDX-License-Identifier: AGPL-3.0-or-later
102
+	UpsertWorkflowJobSecretMask(ctx context.Context, db DBTX, arg UpsertWorkflowJobSecretMaskParams) error
100103
 }
101104
 
102105
 var _ Querier = (*Queries)(nil)
internal/actions/sqlc/workflow_job_secret_masks.sql.goadded
@@ -0,0 +1,50 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+// source: workflow_job_secret_masks.sql
5
+
6
+package actionsdb
7
+
8
+import (
9
+	"context"
10
+)
11
+
12
+const getWorkflowJobSecretMask = `-- name: GetWorkflowJobSecretMask :one
13
+SELECT job_id, ciphertext, nonce, created_at
14
+FROM workflow_job_secret_masks
15
+WHERE job_id = $1
16
+`
17
+
18
+func (q *Queries) GetWorkflowJobSecretMask(ctx context.Context, db DBTX, jobID int64) (WorkflowJobSecretMask, error) {
19
+	row := db.QueryRow(ctx, getWorkflowJobSecretMask, jobID)
20
+	var i WorkflowJobSecretMask
21
+	err := row.Scan(
22
+		&i.JobID,
23
+		&i.Ciphertext,
24
+		&i.Nonce,
25
+		&i.CreatedAt,
26
+	)
27
+	return i, err
28
+}
29
+
30
+const upsertWorkflowJobSecretMask = `-- name: UpsertWorkflowJobSecretMask :exec
31
+
32
+INSERT INTO workflow_job_secret_masks (job_id, ciphertext, nonce)
33
+VALUES ($1, $2, $3)
34
+ON CONFLICT (job_id) DO UPDATE
35
+SET ciphertext = EXCLUDED.ciphertext,
36
+    nonce      = EXCLUDED.nonce,
37
+    created_at = now()
38
+`
39
+
40
+type UpsertWorkflowJobSecretMaskParams struct {
41
+	JobID      int64
42
+	Ciphertext []byte
43
+	Nonce      []byte
44
+}
45
+
46
+// SPDX-License-Identifier: AGPL-3.0-or-later
47
+func (q *Queries) UpsertWorkflowJobSecretMask(ctx context.Context, db DBTX, arg UpsertWorkflowJobSecretMaskParams) error {
48
+	_, err := db.Exec(ctx, upsertWorkflowJobSecretMask, arg.JobID, arg.Ciphertext, arg.Nonce)
49
+	return err
50
+}
internal/admin/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/auth/policy/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/checks/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/issues/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/meta/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/migrationsfs/migrations/0055_workflow_job_secret_masks.sqladded
@@ -0,0 +1,16 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+-- +goose Up
4
+CREATE TABLE workflow_job_secret_masks (
5
+    job_id bigint PRIMARY KEY REFERENCES workflow_jobs(id) ON DELETE CASCADE,
6
+    ciphertext bytea NOT NULL,
7
+    nonce bytea NOT NULL,
8
+    created_at timestamptz NOT NULL DEFAULT now(),
9
+    CONSTRAINT workflow_job_secret_masks_nonce_length
10
+        CHECK (octet_length(nonce) = 12),
11
+    CONSTRAINT workflow_job_secret_masks_ciphertext_nonempty
12
+        CHECK (octet_length(ciphertext) > 0)
13
+);
14
+
15
+-- +goose Down
16
+DROP TABLE IF EXISTS workflow_job_secret_masks;
internal/notif/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/orgs/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/pulls/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/ratelimit/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/repos/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/social/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/users/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/web/handlers/api/runners.gomodified
@@ -23,7 +23,6 @@ import (
2323
 	"github.com/tenseleyFlow/shithub/internal/actions/finalize"
2424
 	"github.com/tenseleyFlow/shithub/internal/actions/runnerlabels"
2525
 	"github.com/tenseleyFlow/shithub/internal/actions/runnertoken"
26
-	"github.com/tenseleyFlow/shithub/internal/actions/secrets"
2726
 	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
2827
 	"github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
2928
 	"github.com/tenseleyFlow/shithub/internal/checks"
@@ -93,7 +92,7 @@ func (h *Handlers) runnerHeartbeat(w http.ResponseWriter, r *http.Request) {
9392
 		return
9493
 	}
9594
 
96
-	job, steps, claimed, err := h.claimRunnerJob(r.Context(), runner.ID, labels, int32(capacity))
95
+	job, steps, resolvedSecrets, claimed, err := h.claimRunnerJob(r.Context(), runner.ID, labels, int32(capacity))
9796
 	if err != nil {
9897
 		h.d.Logger.ErrorContext(r.Context(), "runner heartbeat claim failed", "runner_id", runner.ID, "error", err)
9998
 		writeAPIError(w, http.StatusInternalServerError, "runner heartbeat failed")
@@ -118,12 +117,6 @@ func (h *Handlers) runnerHeartbeat(w http.ResponseWriter, r *http.Request) {
118117
 	}
119118
 	metrics.ActionsRunnerHeartbeatsTotal.WithLabelValues("claimed").Inc()
120119
 	metrics.ActionsRunnerJWTTotal.WithLabelValues("issued").Inc()
121
-	resolvedSecrets, err := h.resolveVisibleSecrets(r.Context(), job.RepoID)
122
-	if err != nil {
123
-		h.d.Logger.ErrorContext(r.Context(), "runner secret resolution failed", "repo_id", job.RepoID, "job_id", job.ID, "error", err)
124
-		writeAPIError(w, http.StatusInternalServerError, "runner secret resolution failed")
125
-		return
126
-	}
127120
 	writeJSON(w, http.StatusOK, presentRunnerClaim(job, steps, resolvedSecrets, token, time.Unix(claims.Exp, 0)))
128121
 }
129122
 
@@ -169,11 +162,11 @@ func (h *Handlers) claimRunnerJob(
169162
 	runnerID int64,
170163
 	labels []string,
171164
 	capacity int32,
172
-) (actionsdb.ClaimQueuedWorkflowJobRow, []actionsdb.ListRunnerStepsForJobRow, bool, error) {
165
+) (actionsdb.ClaimQueuedWorkflowJobRow, []actionsdb.ListRunnerStepsForJobRow, map[string]string, bool, error) {
173166
 	q := actionsdb.New()
174167
 	tx, err := h.d.Pool.Begin(ctx)
175168
 	if err != nil {
176
-		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, err
169
+		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
177170
 	}
178171
 	committed := false
179172
 	defer func() {
@@ -183,11 +176,11 @@ func (h *Handlers) claimRunnerJob(
183176
 	}()
184177
 
185178
 	if _, err := q.LockRunnerByID(ctx, tx, runnerID); err != nil {
186
-		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, err
179
+		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
187180
 	}
188181
 	running, err := q.CountRunningJobsForRunner(ctx, tx, runnerID)
189182
 	if err != nil {
190
-		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, err
183
+		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
191184
 	}
192185
 	if running >= capacity {
193186
 		if _, err := q.HeartbeatRunner(ctx, tx, actionsdb.HeartbeatRunnerParams{
@@ -196,13 +189,13 @@ func (h *Handlers) claimRunnerJob(
196189
 			Capacity: capacity,
197190
 			Status:   actionsdb.WorkflowRunnerStatusBusy,
198191
 		}); err != nil {
199
-			return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, err
192
+			return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
200193
 		}
201194
 		if err := tx.Commit(ctx); err != nil {
202
-			return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, err
195
+			return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
203196
 		}
204197
 		committed = true
205
-		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, nil
198
+		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, nil
206199
 	}
207200
 
208201
 	job, err := q.ClaimQueuedWorkflowJob(ctx, tx, actionsdb.ClaimQueuedWorkflowJobParams{
@@ -211,7 +204,7 @@ func (h *Handlers) claimRunnerJob(
211204
 	})
212205
 	if err != nil {
213206
 		if !errors.Is(err, pgx.ErrNoRows) {
214
-			return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, err
207
+			return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
215208
 		}
216209
 		if _, err := q.HeartbeatRunner(ctx, tx, actionsdb.HeartbeatRunnerParams{
217210
 			ID:       runnerID,
@@ -219,20 +212,27 @@ func (h *Handlers) claimRunnerJob(
219212
 			Capacity: capacity,
220213
 			Status:   actionsdb.WorkflowRunnerStatusIdle,
221214
 		}); err != nil {
222
-			return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, err
215
+			return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
223216
 		}
224217
 		if err := tx.Commit(ctx); err != nil {
225
-			return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, err
218
+			return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
226219
 		}
227220
 		committed = true
228
-		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, nil
221
+		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, nil
229222
 	}
230223
 	if err := q.MarkWorkflowRunRunning(ctx, tx, job.RunID); err != nil {
231
-		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, err
224
+		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
232225
 	}
233226
 	steps, err := q.ListRunnerStepsForJob(ctx, tx, job.ID)
234227
 	if err != nil {
235
-		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, err
228
+		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
229
+	}
230
+	resolvedSecrets, err := h.resolveVisibleSecretsFromDB(ctx, tx, job.RepoID)
231
+	if err != nil {
232
+		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
233
+	}
234
+	if err := h.storeJobSecretMaskSnapshot(ctx, tx, job.ID, secretMaskValues(resolvedSecrets)); err != nil {
235
+		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
236236
 	}
237237
 	status := actionsdb.WorkflowRunnerStatusIdle
238238
 	if running+1 >= capacity {
@@ -244,13 +244,13 @@ func (h *Handlers) claimRunnerJob(
244244
 		Capacity: capacity,
245245
 		Status:   status,
246246
 	}); err != nil {
247
-		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, err
247
+		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
248248
 	}
249249
 	if err := tx.Commit(ctx); err != nil {
250
-		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, false, err
250
+		return actionsdb.ClaimQueuedWorkflowJobRow{}, nil, nil, false, err
251251
 	}
252252
 	committed = true
253
-	return job, steps, true, nil
253
+	return job, steps, resolvedSecrets, true, nil
254254
 }
255255
 
256256
 type runnerJobAuth struct {
@@ -347,7 +347,7 @@ func (h *Handlers) runnerJobLogs(w http.ResponseWriter, r *http.Request) {
347347
 		writeAPIError(w, http.StatusBadRequest, "chunk must be between 1 and 524288 bytes")
348348
 		return
349349
 	}
350
-	values, err := h.logMaskValues(r.Context(), auth.Claims.RepoID)
350
+	values, err := h.jobSecretMaskValues(r.Context(), auth.Job.ID, auth.Claims.RepoID)
351351
 	if err != nil {
352352
 		h.d.Logger.ErrorContext(r.Context(), "runner log mask resolution failed", "repo_id", auth.Claims.RepoID, "job_id", auth.Claims.JobID, "error", err)
353353
 		writeAPIError(w, http.StatusInternalServerError, "log mask resolution failed")
@@ -865,22 +865,30 @@ func (h *Handlers) runnerJobCancelCheck(w http.ResponseWriter, r *http.Request)
865865
 	})
866866
 }
867867
 
868
+type secretResolutionDB interface {
869
+	actionsdb.DBTX
870
+	reposdb.DBTX
871
+}
872
+
868873
 func (h *Handlers) resolveVisibleSecrets(ctx context.Context, repoID int64) (map[string]string, error) {
874
+	return h.resolveVisibleSecretsFromDB(ctx, h.d.Pool, repoID)
875
+}
876
+
877
+func (h *Handlers) resolveVisibleSecretsFromDB(ctx context.Context, db secretResolutionDB, repoID int64) (map[string]string, error) {
869878
 	if h.d.SecretBox == nil {
870879
 		return nil, nil
871880
 	}
872
-	repo, err := reposdb.New().GetRepoByID(ctx, h.d.Pool, repoID)
881
+	repo, err := reposdb.New().GetRepoByID(ctx, db, repoID)
873882
 	if err != nil {
874883
 		return nil, err
875884
 	}
876
-	store := secrets.Deps{Pool: h.d.Pool, Box: h.d.SecretBox, Logger: h.d.Logger}
877885
 	out := map[string]string{}
878886
 	if repo.OwnerOrgID.Valid {
879
-		if err := h.mergeSecrets(ctx, store, secrets.OrgScope(repo.OwnerOrgID.Int64), out); err != nil {
887
+		if err := h.mergeOrgSecrets(ctx, db, repo.OwnerOrgID.Int64, out); err != nil {
880888
 			return nil, err
881889
 		}
882890
 	}
883
-	if err := h.mergeSecrets(ctx, store, secrets.RepoScope(repo.ID), out); err != nil {
891
+	if err := h.mergeRepoSecrets(ctx, db, repo.ID, out); err != nil {
884892
 		return nil, err
885893
 	}
886894
 	if len(out) == 0 {
@@ -889,13 +897,44 @@ func (h *Handlers) resolveVisibleSecrets(ctx context.Context, repoID int64) (map
889897
 	return out, nil
890898
 }
891899
 
892
-func (h *Handlers) mergeSecrets(ctx context.Context, store secrets.Deps, scope secrets.Scope, out map[string]string) error {
893
-	items, err := store.List(ctx, scope)
900
+func (h *Handlers) mergeRepoSecrets(ctx context.Context, db actionsdb.DBTX, repoID int64, out map[string]string) error {
901
+	q := actionsdb.New()
902
+	items, err := q.ListRepoSecrets(ctx, db, pgtype.Int8{Int64: repoID, Valid: true})
894903
 	if err != nil {
895904
 		return err
896905
 	}
897906
 	for _, item := range items {
898
-		plaintext, err := store.Get(ctx, scope, item.Name)
907
+		row, err := q.GetRepoSecret(ctx, db, actionsdb.GetRepoSecretParams{
908
+			RepoID: pgtype.Int8{Int64: repoID, Valid: true},
909
+			Name:   item.Name,
910
+		})
911
+		if err != nil {
912
+			return err
913
+		}
914
+		plaintext, err := h.d.SecretBox.Open(row.Ciphertext, row.Nonce)
915
+		if err != nil {
916
+			return err
917
+		}
918
+		out[item.Name] = string(plaintext)
919
+	}
920
+	return nil
921
+}
922
+
923
+func (h *Handlers) mergeOrgSecrets(ctx context.Context, db actionsdb.DBTX, orgID int64, out map[string]string) error {
924
+	q := actionsdb.New()
925
+	items, err := q.ListOrgSecrets(ctx, db, pgtype.Int8{Int64: orgID, Valid: true})
926
+	if err != nil {
927
+		return err
928
+	}
929
+	for _, item := range items {
930
+		row, err := q.GetOrgSecret(ctx, db, actionsdb.GetOrgSecretParams{
931
+			OrgID: pgtype.Int8{Int64: orgID, Valid: true},
932
+			Name:  item.Name,
933
+		})
934
+		if err != nil {
935
+			return err
936
+		}
937
+		plaintext, err := h.d.SecretBox.Open(row.Ciphertext, row.Nonce)
899938
 		if err != nil {
900939
 			return err
901940
 		}
@@ -912,6 +951,51 @@ func (h *Handlers) logMaskValues(ctx context.Context, repoID int64) ([]string, e
912951
 	return secretMaskValues(resolved), nil
913952
 }
914953
 
954
+func (h *Handlers) storeJobSecretMaskSnapshot(ctx context.Context, db actionsdb.DBTX, jobID int64, values []string) error {
955
+	if h.d.SecretBox == nil {
956
+		return nil
957
+	}
958
+	if values == nil {
959
+		values = []string{}
960
+	}
961
+	payload, err := json.Marshal(values)
962
+	if err != nil {
963
+		return err
964
+	}
965
+	ciphertext, nonce, err := h.d.SecretBox.Seal(payload)
966
+	if err != nil {
967
+		return err
968
+	}
969
+	return actionsdb.New().UpsertWorkflowJobSecretMask(ctx, db, actionsdb.UpsertWorkflowJobSecretMaskParams{
970
+		JobID:      jobID,
971
+		Ciphertext: ciphertext,
972
+		Nonce:      nonce,
973
+	})
974
+}
975
+
976
+func (h *Handlers) jobSecretMaskValues(ctx context.Context, jobID, repoID int64) ([]string, error) {
977
+	if h.d.SecretBox == nil {
978
+		return nil, nil
979
+	}
980
+	row, err := actionsdb.New().GetWorkflowJobSecretMask(ctx, h.d.Pool, jobID)
981
+	if err != nil {
982
+		if errors.Is(err, pgx.ErrNoRows) {
983
+			return h.logMaskValues(ctx, repoID)
984
+		}
985
+		return nil, err
986
+	}
987
+	plaintext, err := h.d.SecretBox.Open(row.Ciphertext, row.Nonce)
988
+	if err != nil {
989
+		return nil, err
990
+	}
991
+	var values []string
992
+	if err := json.Unmarshal(plaintext, &values); err != nil {
993
+		return nil, err
994
+	}
995
+	sort.Strings(values)
996
+	return values, nil
997
+}
998
+
915999
 func secretMaskValues(resolved map[string]string) []string {
9161000
 	if len(resolved) == 0 {
9171001
 		return nil
internal/web/handlers/api/runners_test.gomodified
@@ -204,6 +204,12 @@ func TestRunnerSecretsAreClaimedAndServerScrubsLogs(t *testing.T) {
204204
 	if claim.Job.RunID != runID || claim.Job.Secrets["TOKEN"] != "hunter2" || !containsString(claim.Job.MaskValues, "hunter2") {
205205
 		t.Fatalf("claim did not include masked secret context: %+v", claim.Job)
206206
 	}
207
+	if _, err := actionsdb.New().GetWorkflowJobSecretMask(ctx, pool, claim.Job.ID); err != nil {
208
+		t.Fatalf("GetWorkflowJobSecretMask: %v", err)
209
+	}
210
+	if err := (actionsecrets.Deps{Pool: pool, Box: box}).Set(ctx, actionsecrets.RepoScope(repoID), "TOKEN", []byte("rotated"), userID); err != nil {
211
+		t.Fatalf("rotate secret after claim: %v", err)
212
+	}
207213
 
208214
 	rawLog := []byte("before hunter2 after\n")
209215
 	logBody := fmt.Sprintf(`{"seq":0,"chunk":%q}`, base64.StdEncoding.EncodeToString(rawLog))
internal/webhook/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64
internal/worker/sqlc/models.gomodified
@@ -2397,6 +2397,13 @@ type WorkflowJob struct {
23972397
 	UpdatedAt       pgtype.Timestamptz
23982398
 }
23992399
 
2400
+type WorkflowJobSecretMask struct {
2401
+	JobID      int64
2402
+	Ciphertext []byte
2403
+	Nonce      []byte
2404
+	CreatedAt  pgtype.Timestamptz
2405
+}
2406
+
24002407
 type WorkflowRun struct {
24012408
 	ID               int64
24022409
 	RepoID           int64