@@ -11,6 +11,7 @@ import ( |
| 11 | "io" | 11 | "io" |
| 12 | "net/http" | 12 | "net/http" |
| 13 | "regexp" | 13 | "regexp" |
| | 14 | + "sort" |
| 14 | "strconv" | 15 | "strconv" |
| 15 | "strings" | 16 | "strings" |
| 16 | "time" | 17 | "time" |
@@ -22,12 +23,15 @@ import ( |
| 22 | "github.com/tenseleyFlow/shithub/internal/actions/finalize" | 23 | "github.com/tenseleyFlow/shithub/internal/actions/finalize" |
| 23 | "github.com/tenseleyFlow/shithub/internal/actions/runnerlabels" | 24 | "github.com/tenseleyFlow/shithub/internal/actions/runnerlabels" |
| 24 | "github.com/tenseleyFlow/shithub/internal/actions/runnertoken" | 25 | "github.com/tenseleyFlow/shithub/internal/actions/runnertoken" |
| | 26 | + "github.com/tenseleyFlow/shithub/internal/actions/secrets" |
| 25 | actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" | 27 | actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" |
| 26 | "github.com/tenseleyFlow/shithub/internal/auth/runnerjwt" | 28 | "github.com/tenseleyFlow/shithub/internal/auth/runnerjwt" |
| 27 | "github.com/tenseleyFlow/shithub/internal/checks" | 29 | "github.com/tenseleyFlow/shithub/internal/checks" |
| 28 | checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc" | 30 | checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc" |
| 29 | "github.com/tenseleyFlow/shithub/internal/infra/metrics" | 31 | "github.com/tenseleyFlow/shithub/internal/infra/metrics" |
| 30 | "github.com/tenseleyFlow/shithub/internal/ratelimit" | 32 | "github.com/tenseleyFlow/shithub/internal/ratelimit" |
| | 33 | + reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| | 34 | + "github.com/tenseleyFlow/shithub/internal/runner/scrub" |
| 31 | "github.com/tenseleyFlow/shithub/internal/worker" | 35 | "github.com/tenseleyFlow/shithub/internal/worker" |
| 32 | ) | 36 | ) |
| 33 | | 37 | |
@@ -114,7 +118,13 @@ func (h *Handlers) runnerHeartbeat(w http.ResponseWriter, r *http.Request) { |
| 114 | } | 118 | } |
| 115 | metrics.ActionsRunnerHeartbeatsTotal.WithLabelValues("claimed").Inc() | 119 | metrics.ActionsRunnerHeartbeatsTotal.WithLabelValues("claimed").Inc() |
| 116 | metrics.ActionsRunnerJWTTotal.WithLabelValues("issued").Inc() | 120 | metrics.ActionsRunnerJWTTotal.WithLabelValues("issued").Inc() |
| 117 | - writeJSON(w, http.StatusOK, presentRunnerClaim(job, steps, token, time.Unix(claims.Exp, 0))) | 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 | + } |
| | 127 | + writeJSON(w, http.StatusOK, presentRunnerClaim(job, steps, resolvedSecrets, token, time.Unix(claims.Exp, 0))) |
| 118 | } | 128 | } |
| 119 | | 129 | |
| 120 | func (h *Handlers) authenticateRunner(w http.ResponseWriter, r *http.Request) (actionsdb.GetRunnerByTokenHashRow, bool) { | 130 | func (h *Handlers) authenticateRunner(w http.ResponseWriter, r *http.Request) (actionsdb.GetRunnerByTokenHashRow, bool) { |
@@ -337,15 +347,17 @@ func (h *Handlers) runnerJobLogs(w http.ResponseWriter, r *http.Request) { |
| 337 | writeAPIError(w, http.StatusBadRequest, "chunk must be between 1 and 524288 bytes") | 347 | writeAPIError(w, http.StatusBadRequest, "chunk must be between 1 and 524288 bytes") |
| 338 | return | 348 | return |
| 339 | } | 349 | } |
| | 350 | + values, err := h.logMaskValues(r.Context(), auth.Claims.RepoID) |
| | 351 | + if err != nil { |
| | 352 | + h.d.Logger.ErrorContext(r.Context(), "runner log mask resolution failed", "repo_id", auth.Claims.RepoID, "job_id", auth.Claims.JobID, "error", err) |
| | 353 | + writeAPIError(w, http.StatusInternalServerError, "log mask resolution failed") |
| | 354 | + return |
| | 355 | + } |
| 340 | stepID, ok := h.resolveLogStep(w, r, auth.Job.ID, body.StepID) | 356 | stepID, ok := h.resolveLogStep(w, r, auth.Job.ID, body.StepID) |
| 341 | if !ok { | 357 | if !ok { |
| 342 | return | 358 | return |
| 343 | } | 359 | } |
| 344 | - if _, err := actionsdb.New().AppendStepLogChunk(r.Context(), h.d.Pool, actionsdb.AppendStepLogChunkParams{ | 360 | + if err := h.appendScrubbedLogChunk(r.Context(), stepID, body.Seq, chunk, values); err != nil { |
| 345 | - StepID: stepID, | | |
| 346 | - Seq: body.Seq, | | |
| 347 | - Chunk: chunk, | | |
| 348 | - }); err != nil && !errors.Is(err, pgx.ErrNoRows) { | | |
| 349 | writeAPIError(w, http.StatusInternalServerError, "append log failed") | 361 | writeAPIError(w, http.StatusInternalServerError, "append log failed") |
| 350 | return | 362 | return |
| 351 | } | 363 | } |
@@ -853,6 +865,189 @@ func (h *Handlers) runnerJobCancelCheck(w http.ResponseWriter, r *http.Request) |
| 853 | }) | 865 | }) |
| 854 | } | 866 | } |
| 855 | | 867 | |
| | 868 | +func (h *Handlers) resolveVisibleSecrets(ctx context.Context, repoID int64) (map[string]string, error) { |
| | 869 | + if h.d.SecretBox == nil { |
| | 870 | + return nil, nil |
| | 871 | + } |
| | 872 | + repo, err := reposdb.New().GetRepoByID(ctx, h.d.Pool, repoID) |
| | 873 | + if err != nil { |
| | 874 | + return nil, err |
| | 875 | + } |
| | 876 | + store := secrets.Deps{Pool: h.d.Pool, Box: h.d.SecretBox, Logger: h.d.Logger} |
| | 877 | + out := map[string]string{} |
| | 878 | + if repo.OwnerOrgID.Valid { |
| | 879 | + if err := h.mergeSecrets(ctx, store, secrets.OrgScope(repo.OwnerOrgID.Int64), out); err != nil { |
| | 880 | + return nil, err |
| | 881 | + } |
| | 882 | + } |
| | 883 | + if err := h.mergeSecrets(ctx, store, secrets.RepoScope(repo.ID), out); err != nil { |
| | 884 | + return nil, err |
| | 885 | + } |
| | 886 | + if len(out) == 0 { |
| | 887 | + return nil, nil |
| | 888 | + } |
| | 889 | + return out, nil |
| | 890 | +} |
| | 891 | + |
| | 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) |
| | 894 | + if err != nil { |
| | 895 | + return err |
| | 896 | + } |
| | 897 | + for _, item := range items { |
| | 898 | + plaintext, err := store.Get(ctx, scope, item.Name) |
| | 899 | + if err != nil { |
| | 900 | + return err |
| | 901 | + } |
| | 902 | + out[item.Name] = string(plaintext) |
| | 903 | + } |
| | 904 | + return nil |
| | 905 | +} |
| | 906 | + |
| | 907 | +func (h *Handlers) logMaskValues(ctx context.Context, repoID int64) ([]string, error) { |
| | 908 | + resolved, err := h.resolveVisibleSecrets(ctx, repoID) |
| | 909 | + if err != nil { |
| | 910 | + return nil, err |
| | 911 | + } |
| | 912 | + return secretMaskValues(resolved), nil |
| | 913 | +} |
| | 914 | + |
| | 915 | +func secretMaskValues(resolved map[string]string) []string { |
| | 916 | + if len(resolved) == 0 { |
| | 917 | + return nil |
| | 918 | + } |
| | 919 | + values := make([]string, 0, len(resolved)) |
| | 920 | + for _, value := range resolved { |
| | 921 | + values = append(values, value) |
| | 922 | + } |
| | 923 | + sort.Strings(values) |
| | 924 | + return values |
| | 925 | +} |
| | 926 | + |
| | 927 | +func cloneStringMap(in map[string]string) map[string]string { |
| | 928 | + if len(in) == 0 { |
| | 929 | + return nil |
| | 930 | + } |
| | 931 | + out := make(map[string]string, len(in)) |
| | 932 | + for k, v := range in { |
| | 933 | + out[k] = v |
| | 934 | + } |
| | 935 | + return out |
| | 936 | +} |
| | 937 | + |
| | 938 | +func (h *Handlers) appendScrubbedLogChunk(ctx context.Context, stepID int64, seq int32, chunk []byte, values []string) error { |
| | 939 | + q := actionsdb.New() |
| | 940 | + if len(values) == 0 { |
| | 941 | + _, err := q.AppendStepLogChunk(ctx, h.d.Pool, actionsdb.AppendStepLogChunkParams{ |
| | 942 | + StepID: stepID, |
| | 943 | + Seq: seq, |
| | 944 | + Chunk: chunk, |
| | 945 | + }) |
| | 946 | + if errors.Is(err, pgx.ErrNoRows) { |
| | 947 | + return nil |
| | 948 | + } |
| | 949 | + return err |
| | 950 | + } |
| | 951 | + |
| | 952 | + tx, err := h.d.Pool.Begin(ctx) |
| | 953 | + if err != nil { |
| | 954 | + return err |
| | 955 | + } |
| | 956 | + committed := false |
| | 957 | + defer func() { |
| | 958 | + if !committed { |
| | 959 | + _ = tx.Rollback(ctx) |
| | 960 | + } |
| | 961 | + }() |
| | 962 | + |
| | 963 | + if _, err := q.GetStepLogChunkByStepSeq(ctx, tx, actionsdb.GetStepLogChunkByStepSeqParams{ |
| | 964 | + StepID: stepID, |
| | 965 | + Seq: seq, |
| | 966 | + }); err == nil { |
| | 967 | + if err := tx.Commit(ctx); err != nil { |
| | 968 | + return err |
| | 969 | + } |
| | 970 | + committed = true |
| | 971 | + return nil |
| | 972 | + } else if !errors.Is(err, pgx.ErrNoRows) { |
| | 973 | + return err |
| | 974 | + } |
| | 975 | + |
| | 976 | + var replacements uint64 |
| | 977 | + prev, err := q.GetStepLogChunkBefore(ctx, tx, actionsdb.GetStepLogChunkBeforeParams{ |
| | 978 | + StepID: stepID, |
| | 979 | + Seq: seq, |
| | 980 | + }) |
| | 981 | + if err == nil { |
| | 982 | + if carry := scrubCarryLen(prev.Chunk, values); carry > 0 { |
| | 983 | + prefix := append([]byte(nil), prev.Chunk[:len(prev.Chunk)-carry]...) |
| | 984 | + combined := append(append([]byte(nil), prev.Chunk[len(prev.Chunk)-carry:]...), chunk...) |
| | 985 | + chunk, replacements = scrubChunk(combined, values) |
| | 986 | + if err := q.UpdateStepLogChunk(ctx, tx, actionsdb.UpdateStepLogChunkParams{ |
| | 987 | + ID: prev.ID, |
| | 988 | + Chunk: prefix, |
| | 989 | + }); err != nil { |
| | 990 | + return err |
| | 991 | + } |
| | 992 | + } else { |
| | 993 | + chunk, replacements = scrubChunk(chunk, values) |
| | 994 | + } |
| | 995 | + } else if errors.Is(err, pgx.ErrNoRows) { |
| | 996 | + chunk, replacements = scrubChunk(chunk, values) |
| | 997 | + } else { |
| | 998 | + return err |
| | 999 | + } |
| | 1000 | + |
| | 1001 | + if _, err := q.AppendStepLogChunk(ctx, tx, actionsdb.AppendStepLogChunkParams{ |
| | 1002 | + StepID: stepID, |
| | 1003 | + Seq: seq, |
| | 1004 | + Chunk: chunk, |
| | 1005 | + }); err != nil && !errors.Is(err, pgx.ErrNoRows) { |
| | 1006 | + return err |
| | 1007 | + } |
| | 1008 | + if err := tx.Commit(ctx); err != nil { |
| | 1009 | + return err |
| | 1010 | + } |
| | 1011 | + committed = true |
| | 1012 | + if replacements > 0 { |
| | 1013 | + metrics.ActionsLogScrubReplacementsTotal.WithLabelValues("server").Add(float64(replacements)) |
| | 1014 | + } |
| | 1015 | + return nil |
| | 1016 | +} |
| | 1017 | + |
| | 1018 | +func scrubChunk(chunk []byte, values []string) ([]byte, uint64) { |
| | 1019 | + if len(values) == 0 { |
| | 1020 | + return chunk, 0 |
| | 1021 | + } |
| | 1022 | + s := scrub.New(values) |
| | 1023 | + out := s.Scrub(chunk) |
| | 1024 | + return append(out, s.Flush()...), s.Replacements() |
| | 1025 | +} |
| | 1026 | + |
| | 1027 | +func scrubCarryLen(chunk []byte, values []string) int { |
| | 1028 | + if len(chunk) == 0 || len(values) == 0 { |
| | 1029 | + return 0 |
| | 1030 | + } |
| | 1031 | + text := string(chunk) |
| | 1032 | + keep := 0 |
| | 1033 | + for _, value := range values { |
| | 1034 | + if value == "" { |
| | 1035 | + continue |
| | 1036 | + } |
| | 1037 | + max := len(value) - 1 |
| | 1038 | + if max > len(text) { |
| | 1039 | + max = len(text) |
| | 1040 | + } |
| | 1041 | + for n := max; n > keep; n-- { |
| | 1042 | + if strings.HasSuffix(text, value[:n]) { |
| | 1043 | + keep = n |
| | 1044 | + break |
| | 1045 | + } |
| | 1046 | + } |
| | 1047 | + } |
| | 1048 | + return keep |
| | 1049 | +} |
| | 1050 | + |
| 856 | func (h *Handlers) writeNextTokenResponse( | 1051 | func (h *Handlers) writeNextTokenResponse( |
| 857 | w http.ResponseWriter, | 1052 | w http.ResponseWriter, |
| 858 | r *http.Request, | 1053 | r *http.Request, |
@@ -884,25 +1079,27 @@ type runnerClaimResponse struct { |
| 884 | } | 1079 | } |
| 885 | | 1080 | |
| 886 | type runnerJobPayload struct { | 1081 | type runnerJobPayload struct { |
| 887 | - ID int64 `json:"id"` | 1082 | + ID int64 `json:"id"` |
| 888 | - RunID int64 `json:"run_id"` | 1083 | + RunID int64 `json:"run_id"` |
| 889 | - RepoID int64 `json:"repo_id"` | 1084 | + RepoID int64 `json:"repo_id"` |
| 890 | - RunIndex int64 `json:"run_index"` | 1085 | + RunIndex int64 `json:"run_index"` |
| 891 | - WorkflowFile string `json:"workflow_file"` | 1086 | + WorkflowFile string `json:"workflow_file"` |
| 892 | - WorkflowName string `json:"workflow_name"` | 1087 | + WorkflowName string `json:"workflow_name"` |
| 893 | - HeadSHA string `json:"head_sha"` | 1088 | + HeadSHA string `json:"head_sha"` |
| 894 | - HeadRef string `json:"head_ref"` | 1089 | + HeadRef string `json:"head_ref"` |
| 895 | - Event string `json:"event"` | 1090 | + Event string `json:"event"` |
| 896 | - EventPayload json.RawMessage `json:"event_payload"` | 1091 | + EventPayload json.RawMessage `json:"event_payload"` |
| 897 | - JobKey string `json:"job_key"` | 1092 | + JobKey string `json:"job_key"` |
| 898 | - JobName string `json:"job_name"` | 1093 | + JobName string `json:"job_name"` |
| 899 | - RunsOn string `json:"runs_on"` | 1094 | + RunsOn string `json:"runs_on"` |
| 900 | - Needs []string `json:"needs"` | 1095 | + Needs []string `json:"needs"` |
| 901 | - If string `json:"if"` | 1096 | + If string `json:"if"` |
| 902 | - TimeoutMinutes int32 `json:"timeout_minutes"` | 1097 | + TimeoutMinutes int32 `json:"timeout_minutes"` |
| 903 | - Permissions json.RawMessage `json:"permissions"` | 1098 | + Permissions json.RawMessage `json:"permissions"` |
| 904 | - Env json.RawMessage `json:"env"` | 1099 | + Secrets map[string]string `json:"secrets"` |
| 905 | - Steps []runnerStep `json:"steps"` | 1100 | + MaskValues []string `json:"mask_values"` |
| | 1101 | + Env json.RawMessage `json:"env"` |
| | 1102 | + Steps []runnerStep `json:"steps"` |
| 906 | } | 1103 | } |
| 907 | | 1104 | |
| 908 | type runnerStep struct { | 1105 | type runnerStep struct { |
@@ -922,6 +1119,7 @@ type runnerStep struct { |
| 922 | func presentRunnerClaim( | 1119 | func presentRunnerClaim( |
| 923 | job actionsdb.ClaimQueuedWorkflowJobRow, | 1120 | job actionsdb.ClaimQueuedWorkflowJobRow, |
| 924 | steps []actionsdb.ListRunnerStepsForJobRow, | 1121 | steps []actionsdb.ListRunnerStepsForJobRow, |
| | 1122 | + resolvedSecrets map[string]string, |
| 925 | token string, | 1123 | token string, |
| 926 | expiresAt time.Time, | 1124 | expiresAt time.Time, |
| 927 | ) runnerClaimResponse { | 1125 | ) runnerClaimResponse { |
@@ -962,6 +1160,8 @@ func presentRunnerClaim( |
| 962 | If: job.IfExpr, | 1160 | If: job.IfExpr, |
| 963 | TimeoutMinutes: job.TimeoutMinutes, | 1161 | TimeoutMinutes: job.TimeoutMinutes, |
| 964 | Permissions: rawJSONOrObject(job.Permissions), | 1162 | Permissions: rawJSONOrObject(job.Permissions), |
| | 1163 | + Secrets: cloneStringMap(resolvedSecrets), |
| | 1164 | + MaskValues: secretMaskValues(resolvedSecrets), |
| 965 | Env: rawJSONOrObject(job.JobEnv), | 1165 | Env: rawJSONOrObject(job.JobEnv), |
| 966 | Steps: outSteps, | 1166 | Steps: outSteps, |
| 967 | }, | 1167 | }, |