mirror of
https://gitea.com/gitea/runner.git
synced 2026-08-28 14:57:46 +00:00
fix: improve behaviour across masking, commands and status (#1194)
Fixes 51 bugs discovered via comparison with `actions/runner`. Every fix has test coverage.
### Secrets
- A short secret registered no shifted-base64 form, so `base64("user:$TOKEN")` printed in the clear
- Encoded forms came only from the whole trimmed value, missing padded and per-line spellings
- Masks split only on `\n`, so `::add-mask::a%0Db` registered neither half
- Adds XML, expression-string and quote-trimming encoders
### Workflow commands
- Split at the last `::` or `]` rather than the first, so `::add-mask::a::b` registered no mask
- A command on the last line without a newline was ignored, and `::ADD-MASK::` did nothing
- `##[...]` did not decode `%3B`/`%5D`, properties lost anything after a second `=`
- `$GITHUB_ENV` and `::set-env::` now refuse `NODE_OPTIONS`
### Status
- `continue-on-error` reported failed, a cancelled job reported success, an `if:` error reported cancelled
- File commands ran after `continue-on-error`, failing the job while the step stayed green
- A bad job output aborted the whole run instead of that job
### Steps and actions
- `${{ matrix.* }}` and `${{ strategy.* }}` were empty inside composite actions
- Composite inputs leaked into nested actions as `INPUT_*`, `with:` matched case-sensitively, `pre` failures were dropped
- Docker actions dropped `runs.env` when the caller passed `with: args:`, and caller `args`/`entrypoint` beat the manifest
- An implicit shell ran with `pipefail`, a `shell:` without `{0}` passed without running
- `container.env` overrode job env and every `$GITHUB_ENV` write, heredocs lost leading blank lines, `$GITHUB_PATH` was not BOM-decoded
Written by Claude Opus 5.
Reviewed-on: https://gitea.com/gitea/runner/pulls/1194
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
+33
-29
@@ -64,26 +64,27 @@ func TestValueMasker(t *testing.T) {
|
||||
// A secret that reaches the log through an encoding — a base64 payload, a JSON body, a
|
||||
// URL — must be masked as well: masking only the verbatim value leaks it.
|
||||
func TestValueMaskerEncodedSecrets(t *testing.T) {
|
||||
secret := `p@ss w"rd/1`
|
||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
line string
|
||||
name, secret string
|
||||
encoded []string
|
||||
}{
|
||||
{"verbatim", "the token is " + secret},
|
||||
{"base64", "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(secret))},
|
||||
{"json", `{"token":"` + jsonStringEscape(secret) + `"}`},
|
||||
{"query escaped", "https://example.com/?token=" + url.QueryEscape(secret)},
|
||||
{"path escaped", "https://example.com/" + url.PathEscape(secret) + "/x"},
|
||||
{"common encodings", `p@ss w"rd/1`, []string{
|
||||
`p@ss w"rd/1`, base64.StdEncoding.EncodeToString([]byte(`p@ss w"rd/1`)),
|
||||
jsonStringEscape(`p@ss w"rd/1`), url.PathEscape(`p@ss w"rd/1`),
|
||||
}},
|
||||
{"XML expression and quotes", `"a'b&c<d>"`, []string{
|
||||
`"a'b&c<d>"`, `"a''b&c<d>"`, `a'b&c<d>`,
|
||||
}},
|
||||
{"URI spaces", "a b", []string{"a%20b", "a+b"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: tc.line})
|
||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": tc.secret}))
|
||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: strings.Join(tc.encoded, " ")})
|
||||
|
||||
assert.Contains(t, entry.Message, "***")
|
||||
assert.NotContains(t, entry.Message, secret)
|
||||
assert.NotContains(t, entry.Message, base64.StdEncoding.EncodeToString([]byte(secret)))
|
||||
assert.NotContains(t, entry.Message, url.QueryEscape(secret))
|
||||
for _, disallowed := range tc.encoded {
|
||||
assert.NotContains(t, entry.Message, disallowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -124,15 +125,21 @@ func TestValueMaskerHidesExtraMasks(t *testing.T) {
|
||||
|
||||
// ::add-mask:: values go through the same masker, so they get the same treatment.
|
||||
func TestValueMaskerEncodedMasks(t *testing.T) {
|
||||
masks := []string{"s3cr3t value"}
|
||||
masks := []string{"s3cr3t value", "first\rsecond", " s3cr3t "}
|
||||
masker := valueMasker(false, AppendSecretMaskers(nil, nil))
|
||||
|
||||
entry := masker(&logrus.Entry{
|
||||
Context: WithMasks(t.Context(), &masks),
|
||||
Message: "encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")),
|
||||
})
|
||||
|
||||
assert.Equal(t, "encoded: ***", entry.Message)
|
||||
for _, tc := range []struct {
|
||||
line, want string
|
||||
}{
|
||||
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t value")), "encoded: ***"},
|
||||
{"first and second", "*** and ***"},
|
||||
{"encoded: " + base64.StdEncoding.EncodeToString([]byte(" s3cr3t ")), "encoded: ***"},
|
||||
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"},
|
||||
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"},
|
||||
} {
|
||||
entry := masker(&logrus.Entry{Context: WithMasks(t.Context(), &masks), Message: tc.line})
|
||||
assert.Equal(t, tc.want, entry.Message, tc.line)
|
||||
}
|
||||
}
|
||||
|
||||
// A token in a Basic auth header is base64'd together with the user name, so the token's
|
||||
@@ -140,7 +147,7 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
|
||||
// alignments must be masked as well, or `Authorization: Basic base64("user:token")` leaks
|
||||
// the token to anyone who can decode the log.
|
||||
func TestValueMaskerBase64Alignments(t *testing.T) {
|
||||
secret := "s3cr3t-token-value"
|
||||
secret := "s3cr3t"
|
||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": secret}))
|
||||
|
||||
// One prefix per alignment: len%3 of 0, 1 and 2.
|
||||
@@ -150,8 +157,6 @@ func TestValueMaskerBase64Alignments(t *testing.T) {
|
||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
|
||||
|
||||
assert.Contains(t, entry.Message, "***")
|
||||
// The aligned middle of the secret must be gone, so the payload can no longer be
|
||||
// decoded back into the token.
|
||||
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
|
||||
decodable := strings.TrimPrefix(entry.Message, "Authorization: Basic ")
|
||||
decoded, err := base64.StdEncoding.DecodeString(decodable)
|
||||
@@ -190,15 +195,14 @@ func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
|
||||
// JSON, query and path escaping all leave it unchanged.
|
||||
pairs := AppendSecretMasker(nil, "plaintoken")
|
||||
assert.Equal(t, []string{
|
||||
"plaintoken", "***",
|
||||
base64.StdEncoding.EncodeToString([]byte("plaintoken")), "***",
|
||||
// The two shifted alignments, each without its leading and trailing group.
|
||||
"YWludG9r", "***",
|
||||
"bGFpbnRv", "***",
|
||||
"plaintoken", "***", "cGxhaW50b2tlbg==", "***", "bGFpbnRva2Vu", "***", "YWludG9rZW4=", "***",
|
||||
"aW50b2tl", "***", "YWludG9r", "***", "bGFpbnRv", "***",
|
||||
}, pairs)
|
||||
|
||||
// Too short to mask.
|
||||
assert.Empty(t, AppendSecretMasker(nil, "x"))
|
||||
assert.Empty(t, AppendSecretMasker(nil, " \t"))
|
||||
assert.NotContains(t, AppendSecretMasker(nil, `"123456"`), "123456")
|
||||
}
|
||||
|
||||
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user