@@ -22,10 +22,12 @@ import ( |
| 22 | 22 | |
| 23 | 23 | "github.com/tenseleyFlow/shithub/internal/actions/finalize" |
| 24 | 24 | "github.com/tenseleyFlow/shithub/internal/actions/runnertoken" |
| 25 | + actionsecrets "github.com/tenseleyFlow/shithub/internal/actions/secrets" |
| 25 | 26 | actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" |
| 26 | 27 | "github.com/tenseleyFlow/shithub/internal/actions/trigger" |
| 27 | 28 | "github.com/tenseleyFlow/shithub/internal/actions/workflow" |
| 28 | 29 | "github.com/tenseleyFlow/shithub/internal/auth/runnerjwt" |
| 30 | + "github.com/tenseleyFlow/shithub/internal/auth/secretbox" |
| 29 | 31 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 30 | 32 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 31 | 33 | "github.com/tenseleyFlow/shithub/internal/testing/dbtest" |
@@ -164,6 +166,133 @@ func TestRunnerHeartbeatClaimsQueuedJob(t *testing.T) { |
| 164 | 166 | } |
| 165 | 167 | } |
| 166 | 168 | |
| 169 | +func TestRunnerSecretsAreClaimedAndServerScrubsLogs(t *testing.T) { |
| 170 | + ctx := context.Background() |
| 171 | + pool := dbtest.NewTestDB(t) |
| 172 | + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 173 | + repoID, userID := setupRunnerAPIRepo(t, pool) |
| 174 | + runID := enqueueRunnerAPIRun(t, pool, logger, repoID, userID) |
| 175 | + box := testRunnerAPISecretBox(t) |
| 176 | + if err := (actionsecrets.Deps{Pool: pool, Box: box}).Set(ctx, actionsecrets.RepoScope(repoID), "TOKEN", []byte("hunter2"), userID); err != nil { |
| 177 | + t.Fatalf("Set secret: %v", err) |
| 178 | + } |
| 179 | + |
| 180 | + token, _ := registerRunnerForTest(t, pool, []string{"ubuntu-latest", "linux"}, 1) |
| 181 | + signer := runnerAPISigner(t, time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)) |
| 182 | + router := newRunnerAPIRouterWithSecretBox(t, pool, logger, signer, box) |
| 183 | + |
| 184 | + req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/heartbeat", |
| 185 | + strings.NewReader(`{"labels":["ubuntu-latest","linux"],"capacity":1}`)) |
| 186 | + req.Header.Set("Authorization", "Bearer "+token) |
| 187 | + rr := httptest.NewRecorder() |
| 188 | + router.ServeHTTP(rr, req) |
| 189 | + if rr.Code != http.StatusOK { |
| 190 | + t.Fatalf("heartbeat status: got %d, want 200; body=%s", rr.Code, rr.Body.String()) |
| 191 | + } |
| 192 | + var claim struct { |
| 193 | + Token string `json:"token"` |
| 194 | + Job struct { |
| 195 | + ID int64 `json:"id"` |
| 196 | + RunID int64 `json:"run_id"` |
| 197 | + Secrets map[string]string `json:"secrets"` |
| 198 | + MaskValues []string `json:"mask_values"` |
| 199 | + } `json:"job"` |
| 200 | + } |
| 201 | + if err := json.Unmarshal(rr.Body.Bytes(), &claim); err != nil { |
| 202 | + t.Fatalf("decode claim: %v", err) |
| 203 | + } |
| 204 | + if claim.Job.RunID != runID || claim.Job.Secrets["TOKEN"] != "hunter2" || !containsString(claim.Job.MaskValues, "hunter2") { |
| 205 | + t.Fatalf("claim did not include masked secret context: %+v", claim.Job) |
| 206 | + } |
| 207 | + |
| 208 | + rawLog := []byte("before hunter2 after\n") |
| 209 | + logBody := fmt.Sprintf(`{"seq":0,"chunk":%q}`, base64.StdEncoding.EncodeToString(rawLog)) |
| 210 | + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/jobs/%d/logs", claim.Job.ID), strings.NewReader(logBody)) |
| 211 | + req.Header.Set("Authorization", "Bearer "+claim.Token) |
| 212 | + rr = httptest.NewRecorder() |
| 213 | + router.ServeHTTP(rr, req) |
| 214 | + if rr.Code != http.StatusAccepted { |
| 215 | + t.Fatalf("logs status: got %d, want 202; body=%s", rr.Code, rr.Body.String()) |
| 216 | + } |
| 217 | + step, err := actionsdb.New().GetFirstStepForJob(ctx, pool, claim.Job.ID) |
| 218 | + if err != nil { |
| 219 | + t.Fatalf("GetFirstStepForJob: %v", err) |
| 220 | + } |
| 221 | + chunks, err := actionsdb.New().ListStepLogChunks(ctx, pool, actionsdb.ListStepLogChunksParams{ |
| 222 | + StepID: step.ID, |
| 223 | + Seq: -1, |
| 224 | + Limit: 10, |
| 225 | + }) |
| 226 | + if err != nil { |
| 227 | + t.Fatalf("ListStepLogChunks: %v", err) |
| 228 | + } |
| 229 | + if len(chunks) != 1 { |
| 230 | + t.Fatalf("chunks: %#v", chunks) |
| 231 | + } |
| 232 | + got := string(chunks[0].Chunk) |
| 233 | + if strings.Contains(got, "hunter2") || got != "before *** after\n" { |
| 234 | + t.Fatalf("stored log chunk was not scrubbed: %q", got) |
| 235 | + } |
| 236 | +} |
| 237 | + |
| 238 | +func TestRunnerServerScrubsSecretSplitAcrossLogPosts(t *testing.T) { |
| 239 | + ctx := context.Background() |
| 240 | + pool := dbtest.NewTestDB(t) |
| 241 | + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 242 | + repoID, userID := setupRunnerAPIRepo(t, pool) |
| 243 | + enqueueRunnerAPIRun(t, pool, logger, repoID, userID) |
| 244 | + box := testRunnerAPISecretBox(t) |
| 245 | + if err := (actionsecrets.Deps{Pool: pool, Box: box}).Set(ctx, actionsecrets.RepoScope(repoID), "TOKEN", []byte("hunter2"), userID); err != nil { |
| 246 | + t.Fatalf("Set secret: %v", err) |
| 247 | + } |
| 248 | + |
| 249 | + token, _ := registerRunnerForTest(t, pool, []string{"ubuntu-latest", "linux"}, 1) |
| 250 | + signer := runnerAPISigner(t, time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC)) |
| 251 | + router := newRunnerAPIRouterWithSecretBox(t, pool, logger, signer, box) |
| 252 | + |
| 253 | + req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/heartbeat", |
| 254 | + strings.NewReader(`{"labels":["ubuntu-latest","linux"],"capacity":1}`)) |
| 255 | + req.Header.Set("Authorization", "Bearer "+token) |
| 256 | + rr := httptest.NewRecorder() |
| 257 | + router.ServeHTTP(rr, req) |
| 258 | + if rr.Code != http.StatusOK { |
| 259 | + t.Fatalf("heartbeat status: got %d, want 200; body=%s", rr.Code, rr.Body.String()) |
| 260 | + } |
| 261 | + var claim struct { |
| 262 | + Token string `json:"token"` |
| 263 | + Job struct { |
| 264 | + ID int64 `json:"id"` |
| 265 | + } `json:"job"` |
| 266 | + } |
| 267 | + if err := json.Unmarshal(rr.Body.Bytes(), &claim); err != nil { |
| 268 | + t.Fatalf("decode claim: %v", err) |
| 269 | + } |
| 270 | + |
| 271 | + next := postRunnerLogChunk(t, router, claim.Job.ID, claim.Token, 0, []byte("before hun")) |
| 272 | + next = postRunnerLogChunk(t, router, claim.Job.ID, next, 1, []byte("ter2 after\n")) |
| 273 | + |
| 274 | + step, err := actionsdb.New().GetFirstStepForJob(ctx, pool, claim.Job.ID) |
| 275 | + if err != nil { |
| 276 | + t.Fatalf("GetFirstStepForJob: %v", err) |
| 277 | + } |
| 278 | + chunks, err := actionsdb.New().ListStepLogChunks(ctx, pool, actionsdb.ListStepLogChunksParams{ |
| 279 | + StepID: step.ID, |
| 280 | + Seq: -1, |
| 281 | + Limit: 10, |
| 282 | + }) |
| 283 | + if err != nil { |
| 284 | + t.Fatalf("ListStepLogChunks: %v", err) |
| 285 | + } |
| 286 | + var combined strings.Builder |
| 287 | + for _, chunk := range chunks { |
| 288 | + combined.Write(chunk.Chunk) |
| 289 | + } |
| 290 | + got := combined.String() |
| 291 | + if strings.Contains(got, "hunter2") || got != "before *** after\n" { |
| 292 | + t.Fatalf("stored log chunks were not scrubbed across boundary: chunks=%#v combined=%q next=%q", chunks, got, next) |
| 293 | + } |
| 294 | +} |
| 295 | + |
| 167 | 296 | func TestRunnerHeartbeatRejectsBadToken(t *testing.T) { |
| 168 | 297 | pool := dbtest.NewTestDB(t) |
| 169 | 298 | router := newRunnerAPIRouter(t, pool, slog.New(slog.NewTextHandler(io.Discard, nil)), runnerAPISigner(t, time.Now())) |
@@ -311,6 +440,28 @@ func TestRunnerStepStatusEnqueuesFinalizeWorker(t *testing.T) { |
| 311 | 440 | } |
| 312 | 441 | } |
| 313 | 442 | |
| 443 | +func postRunnerLogChunk(t *testing.T, router http.Handler, jobID int64, token string, seq int32, chunk []byte) string { |
| 444 | + t.Helper() |
| 445 | + body := fmt.Sprintf(`{"seq":%d,"chunk":%q}`, seq, base64.StdEncoding.EncodeToString(chunk)) |
| 446 | + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/jobs/%d/logs", jobID), strings.NewReader(body)) |
| 447 | + req.Header.Set("Authorization", "Bearer "+token) |
| 448 | + rr := httptest.NewRecorder() |
| 449 | + router.ServeHTTP(rr, req) |
| 450 | + if rr.Code != http.StatusAccepted { |
| 451 | + t.Fatalf("logs status: got %d, want 202; body=%s", rr.Code, rr.Body.String()) |
| 452 | + } |
| 453 | + var resp struct { |
| 454 | + NextToken string `json:"next_token"` |
| 455 | + } |
| 456 | + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { |
| 457 | + t.Fatalf("decode log response: %v", err) |
| 458 | + } |
| 459 | + if resp.NextToken == "" { |
| 460 | + t.Fatalf("empty next token in log response: %s", rr.Body.String()) |
| 461 | + } |
| 462 | + return resp.NextToken |
| 463 | +} |
| 464 | + |
| 314 | 465 | func newRunnerAPIRouter( |
| 315 | 466 | t *testing.T, |
| 316 | 467 | pool *pgxpool.Pool, |
@@ -337,6 +488,41 @@ func newRunnerAPIRouter( |
| 337 | 488 | return r |
| 338 | 489 | } |
| 339 | 490 | |
| 491 | +func newRunnerAPIRouterWithSecretBox( |
| 492 | + t *testing.T, |
| 493 | + pool *pgxpool.Pool, |
| 494 | + logger *slog.Logger, |
| 495 | + signer *runnerjwt.Signer, |
| 496 | + box *secretbox.Box, |
| 497 | +) http.Handler { |
| 498 | + t.Helper() |
| 499 | + h, err := apih.New(apih.Deps{ |
| 500 | + Pool: pool, |
| 501 | + Logger: logger, |
| 502 | + RunnerJWT: signer, |
| 503 | + SecretBox: box, |
| 504 | + }) |
| 505 | + if err != nil { |
| 506 | + t.Fatalf("api.New: %v", err) |
| 507 | + } |
| 508 | + r := chi.NewRouter() |
| 509 | + h.Mount(r) |
| 510 | + return r |
| 511 | +} |
| 512 | + |
| 513 | +func testRunnerAPISecretBox(t *testing.T) *secretbox.Box { |
| 514 | + t.Helper() |
| 515 | + key, err := secretbox.GenerateKey() |
| 516 | + if err != nil { |
| 517 | + t.Fatalf("GenerateKey: %v", err) |
| 518 | + } |
| 519 | + box, err := secretbox.FromBytes(key) |
| 520 | + if err != nil { |
| 521 | + t.Fatalf("secretbox.FromBytes: %v", err) |
| 522 | + } |
| 523 | + return box |
| 524 | +} |
| 525 | + |
| 340 | 526 | func setupRunnerAPIRepo(t *testing.T, pool *pgxpool.Pool) (repoID, userID int64) { |
| 341 | 527 | t.Helper() |
| 342 | 528 | ctx := context.Background() |
@@ -420,6 +606,15 @@ func registerRunnerForTest(t *testing.T, pool *pgxpool.Pool, labels []string, ca |
| 420 | 606 | return token, runner.ID |
| 421 | 607 | } |
| 422 | 608 | |
| 609 | +func containsString(items []string, want string) bool { |
| 610 | + for _, item := range items { |
| 611 | + if item == want { |
| 612 | + return true |
| 613 | + } |
| 614 | + } |
| 615 | + return false |
| 616 | +} |
| 617 | + |
| 423 | 618 | func runnerAPISigner(t *testing.T, now time.Time) *runnerjwt.Signer { |
| 424 | 619 | t.Helper() |
| 425 | 620 | signer, err := runnerjwt.NewFromKey( |