Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions actions/setup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ This action copies files from `actions/setup/`, including:

All files are copied from the committed `js/` and `sh/` directories which contain the source of truth for all JavaScript and shell scripts.

## OTLP credential masking

Setup registers runner masks before sending its OTLP span. Full header strings
remain masked. Individual plain header values are masked only when they contain
at least eight characters and are not entirely digits. This policy also applies
to percent-encoded values after decoding, so placeholders such as `X-No-Auth=true`
do not register a standalone `true` mask that suppresses boolean job outputs.

Bearer and Basic credentials retain the four-character minimum, including bare
tokens and decoded Basic-auth passwords. JavaScript setup covers
`OTEL_EXPORTER_OTLP_HEADERS`, `GH_AW_OTLP_ALL_HEADERS`, and headers in
`GH_AW_OTLP_ENDPOINTS`, including disabled endpoints. Artifact and step-summary
redaction keep the broader four-character collection policy for plain header
values; the runner-mask policy does not reduce that redaction.

## Development

### Structured issue reports
Expand Down
4 changes: 2 additions & 2 deletions actions/setup/js/action_setup_otlp.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ require("./shim.cjs");
const { appendFileSync } = require("fs");
const { nowMs } = require("./performance_now.cjs");
const { getActionInput } = require("./action_input_utils.cjs");
const { maskSecret, getOTLPSecretValues } = require("./actions_secret_masking.cjs");
const { maskSecret, getOTLPRunnerMaskValues } = require("./actions_secret_masking.cjs");

/**
* Append a key=value line to a GitHub Actions file (GITHUB_OUTPUT or GITHUB_ENV)
Expand Down Expand Up @@ -167,7 +167,7 @@ async function run() {

const endpoints = process.env.GH_AW_OTLP_ENDPOINTS;
const parsedEndpoints = parseOTLPEndpoints();
for (const secret of getOTLPSecretValues()) maskSecret(secret);
for (const secret of getOTLPRunnerMaskValues()) maskSecret(secret);
if (endpoints) {
const primaryEndpoint = getPrimaryOTLPEndpoint(endpoints);
const currentEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "";
Expand Down
43 changes: 43 additions & 0 deletions actions/setup/js/action_setup_otlp.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,49 @@ describe("action_setup_otlp.cjs", () => {
expect(typeof run).toBe("function");
});

it.each(["OTEL_EXPORTER_OTLP_HEADERS", "GH_AW_OTLP_ALL_HEADERS", "GH_AW_OTLP_ENDPOINTS"])("does not register plain placeholders from %s as standalone runner masks", async variable => {
const headers = [
"X-No-Auth=true",
"X-False=false",
"X-Null=null",
"X-Short=abcd",
"X-Seven=abcdefg",
"X-Digits=12345678",
"X-Encoded-Boolean=%74%72%75%65",
"X-Encoded-Short=%61%62%63%64",
"X-Encoded-Digits=%31%32%33%34%35%36%37%38",
"X-Key=abcdefgh",
"X-Encoded-Key=%61bcdefgh",
].join(",");
process.env[variable] = variable === "GH_AW_OTLP_ENDPOINTS" ? JSON.stringify([{ url: "", headers }]) : headers;
mockSendJobSetupSpan.mockImplementationOnce(async () => {
expect(mockCore.setSecret.mock.calls.map(([value]) => value).sort()).toEqual([headers, "abcdefgh", "%61bcdefgh"].sort());
return { traceId: VALID_TRACE_ID, spanId: VALID_SPAN_ID };
});

await run();

expect(mockCore.setSecret.mock.calls.map(([value]) => value).sort()).toEqual([headers, "abcdefgh", "%61bcdefgh"].sort());
});

it.each(["Bearer abcd", "bearer 1234", "Bearer true", "Bearer abc", "Basic dTp0cnVl", "basic dToxMjM0"])("preserves short auth credential runner masks for %s", async authorization => {
const encoded = encodeURIComponent(authorization);
const headers = `Authorization=${encoded}`;
process.env.OTEL_EXPORTER_OTLP_HEADERS = headers;
const credential = authorization.split(" ")[1];
const expected = new Set([headers, encoded, authorization]);
if (credential.length >= 4) expected.add(credential);
if (authorization.toLowerCase().startsWith("basic ")) {
const decoded = Buffer.from(credential, "base64").toString("utf8");
expected.add(decoded);
expected.add(decoded.split(":")[1]);
}

await run();

expect(mockCore.setSecret.mock.calls.map(([value]) => value).sort()).toEqual([...expected].sort());
});

it("masks decoded OTLP credentials before sending spans without changing configured headers", async () => {
const password = "synthetic-grafana-cloud-token";
const basic = `Basic ${Buffer.from(`123456:${password}`).toString("base64")}`;
Expand Down
4 changes: 2 additions & 2 deletions actions/setup/js/actions_secret_masking.cjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @ts-check

const { getOTLPSecretValues } = require("./otlp_header_helpers.cjs");
const { getOTLPSecretValues, getOTLPRunnerMaskValues } = require("./otlp_header_helpers.cjs");

/**
* Escape a value for a GitHub Actions workflow command payload.
Expand Down Expand Up @@ -39,4 +39,4 @@ function maskSecret(value) {
}
}

module.exports = { escapeWorkflowCommandValue, maskSecret, getOTLPSecretValues };
module.exports = { escapeWorkflowCommandValue, maskSecret, getOTLPSecretValues, getOTLPRunnerMaskValues };
33 changes: 29 additions & 4 deletions actions/setup/js/otlp_header_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ function getAuthorizationSecretValues(value) {
/**
* Collect OTLP credentials independently of setup log capture or token prefixes.
* Include headers from disabled endpoints: their credentials still exist in env.
* @param {boolean} forRunnerMasking - Avoid registering plain placeholder values as runner masks
* @returns {string[]} Unique secret values, longest first
*/
function getOTLPSecretValues() {
function collectOTLPSecretValues(forRunnerMasking) {
const headers = [process.env.OTEL_EXPORTER_OTLP_HEADERS, process.env.GH_AW_OTLP_ALL_HEADERS];
const endpointsRaw = process.env.GH_AW_OTLP_ENDPOINTS;
if (endpointsRaw) {
Expand All @@ -78,13 +79,18 @@ function getOTLPSecretValues() {
/** @type {Set<string>} */
const secrets = new Set();
for (const raw of headers) {
if (forRunnerMasking && raw && raw.length >= 4) secrets.add(raw);
for (const pair of (raw || "").split(",")) {
const separator = pair.indexOf("=");
if (separator <= 0) continue;
const encoded = pair.slice(separator + 1).trim();
if (encoded.length >= 4) secrets.add(encoded);
// Parse individually so duplicate header names never overwrite a secret.
for (const value of Object.values(parseOTLPHeaders(pair))) {
const values = Object.values(parseOTLPHeaders(pair));
const value = values[0] ?? encoded;
const isAuthorization = /^(Basic|Bearer)\s+(.+)$/i.test(value.trim());
if (forRunnerMasking && !isAuthorization && (value.length < 8 || /^\d+$/.test(value))) continue;
if (encoded.length >= 4) secrets.add(encoded);
for (const value of values) {
for (const secret of getAuthorizationSecretValues(value)) secrets.add(secret);
}
}
Expand All @@ -95,4 +101,23 @@ function getOTLPSecretValues() {
return [...secrets].sort((a, b) => b.length - a.length);
}

module.exports = { parseOTLPHeaders, getAuthorizationSecretValues, getOTLPSecretValues };
/**
* Collect credentials for artifact and step-summary redaction, including plain
* header values at the four-character minimum.
* @returns {string[]}
*/
function getOTLPSecretValues() {
return collectOTLPSecretValues(false);
}

/**
* Select runner masks without suppressing outputs matching plain placeholders.
* Auth schemes retain the four-character credential minimum, including decoded
* Basic passwords. Full headers remain masked even when a plain value is skipped.
* @returns {string[]}
*/
function getOTLPRunnerMaskValues() {
return collectOTLPSecretValues(true);
}

module.exports = { parseOTLPHeaders, getAuthorizationSecretValues, getOTLPSecretValues, getOTLPRunnerMaskValues };
16 changes: 16 additions & 0 deletions actions/setup/js/otlp_header_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ describe("otlp_header_helpers.cjs", () => {
}
});

it("retains plain placeholders for artifact and step-summary redaction", () => {
const { redactArtifactContent, redactStepSummaryContent } = require("./redact_secrets.cjs");
const placeholders = ["true", "false", "null", "abcd", "abcdefg", "12345678", "%74%72%75%65", "%31%32%33%34%35%36%37%38"];
vi.stubEnv("OTEL_EXPORTER_OTLP_HEADERS", placeholders.map((value, index) => `X-Placeholder-${index}=${value}`).join(","));
vi.stubEnv("GH_AW_OTLP_ALL_HEADERS", "");
vi.stubEnv("GH_AW_OTLP_ENDPOINTS", "");

const values = getOTLPSecretValues();
const content = JSON.stringify({ values: placeholders });
const expected = JSON.stringify({ values: placeholders.map(() => "***") });

expect(values.sort()).toEqual([...placeholders].sort());
expect(redactArtifactContent(content, [], values).content).toBe(expected);
expect(redactStepSummaryContent(content)).toBe(expected);
});

it("keeps eval verification rejecting unredacted OTLP credentials", () => {
const { verifyRedaction } = require("./redact_evals_results.cjs");
const { redactStepSummaryContent } = require("./redact_secrets.cjs");
Expand Down
8 changes: 6 additions & 2 deletions actions/setup/sh/mask_otlp_headers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ set +o histexpand
# Up to three levels of masking are applied to each headers string:
# 1. The entire comma-separated header pairs string.
# 2. Each individual header value extracted from the pairs, so that a token
# appearing without its header name prefix is also redacted.
# appearing without its header name prefix is also redacted. Only non-numeric
# values of at least 8 characters are masked individually, avoiding boolean
# and short placeholder values that would suppress unrelated job outputs.
# 3. For Authorization-style "Bearer <token>" credentials, the raw token after
# stripping the "Bearer " scheme prefix, so it is masked even when it appears
# without the scheme (e.g. in downstream tool logs).
Expand Down Expand Up @@ -55,7 +57,9 @@ mask_headers() {
mapfile -t _pairs < <(printf '%s' "$_headers" | tr ',' '\n')
for _pair in "${_pairs[@]}"; do
_val="${_pair#*=}"
emit_mask "$_val"
if [[ "${#_val}" -ge 8 && ! "$_val" =~ ^[0-9]+$ ]]; then
Comment thread
pelikhan marked this conversation as resolved.
emit_mask "$_val"
fi
_no_bearer="${_val#Bearer }"
if [ "$_no_bearer" != "$_val" ]; then
emit_mask "$_no_bearer"
Expand Down
53 changes: 48 additions & 5 deletions pkg/workflow/observability_otlp_mask_script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,59 @@ func TestMaskOTLPHeadersScript(t *testing.T) {
{
name: "short values are not emitted as masks",
env: []string{
"OTEL_EXPORTER_OTLP_HEADERS=Authorization=4,Api-Key=1234,Edge=abcd,Trace=abc",
"OTEL_EXPORTER_OTLP_HEADERS=Authorization=4,Api-Key=1234,Edge=abcd,Trace=abc,Short=abcdefg,Token=abcdefgh",
},
want: []string{
"::add-mask::Authorization=4,Api-Key=1234,Edge=abcd,Trace=abc",
"::add-mask::1234",
"::add-mask::abcd",
"::add-mask::Authorization=4,Api-Key=1234,Edge=abcd,Trace=abc,Short=abcdefg,Token=abcdefgh",
"::add-mask::abcdefgh",
},
wantNot: []string{
"::add-mask::4",
"::add-mask::1234",
"::add-mask::abcd",
"::add-mask::abc",
"::add-mask::abcdefg",
},
},
{
name: "boolean placeholder preserves true job outputs",
env: []string{
"OTEL_EXPORTER_OTLP_HEADERS=X-No-Auth=true",
},
want: []string{"::add-mask::X-No-Auth=true"},
wantNot: []string{
"::add-mask::true",
},
},
{
name: "boolean and numeric placeholders are not individual masks",
env: []string{
"OTEL_EXPORTER_OTLP_HEADERS=X-Flag=false,X-Enabled=TRUE,X-Disabled=FALSE,X-Id=1234567890123456",
"GH_AW_OTLP_ALL_HEADERS=X-No-Auth=true,X-Id=12345678,Api-Key=real-credential",
},
want: []string{
"::add-mask::X-Flag=false,X-Enabled=TRUE,X-Disabled=FALSE,X-Id=1234567890123456",
"::add-mask::X-No-Auth=true,X-Id=12345678,Api-Key=real-credential",
"::add-mask::real-credential",
},
wantNot: []string{
"::add-mask::true",
"::add-mask::false",
"::add-mask::TRUE",
"::add-mask::FALSE",
"::add-mask::12345678",
"::add-mask::1234567890123456",
},
},
{
name: "short bearer credential retains raw token mask",
env: []string{
"OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer " + "abcd",
},
want: []string{
"::add-mask::Authorization=Bearer " + "abcd",
"::add-mask::Bearer " + "abcd",
"::add-mask::abcd",
},
},
}
Expand All @@ -85,7 +128,7 @@ func TestMaskOTLPHeadersScript(t *testing.T) {
output := string(out)
normalizedOutput := "\n" + strings.TrimSpace(output) + "\n"
for _, want := range tt.want {
assert.Contains(t, output, want)
assert.Contains(t, normalizedOutput, "\n"+want+"\n")
}
for _, wantNot := range tt.wantNot {
assert.NotContains(t, normalizedOutput, "\n"+wantNot+"\n")
Expand Down
Loading