action: trim sensitive env values before GitHub Actions log masking (#698)

* action: trim sensitive env values before GitHub Actions log masking

GitHub Actions' log masking is line-based: a secret value containing a
newline only registers the first line as a mask, leaving the remainder
exposed verbatim in logs. A trailing newline copied from a terminal into
a GitHub Actions secret (e.g. ANTHROPIC_API_KEY) was enough to leak
"a large part of the key" in run logs (pullfrog/pullfrog#41).

normalizeEnv now trims leading/trailing whitespace from any value whose
key matches the sensitive name pattern, masks the cleaned value, and
warns when whitespace was stripped so the user notices the source.
sanitizeSecret is reused for dbSecrets injection in main.ts. The three
secret-store PUT/POST routes also trim values defensively, matching the
existing name.trim() pattern.

Real multi-line secrets are not used in practice — even GITHUB_PRIVATE_KEY
PEMs are stored single-line with escaped \n and unescaped at the point of
use — so a straight trim() is safe.

* action: address review — use core.setSecret for masking, don't zero whitespace-only

Pullfrog's review of #698 caught two real issues in the original fix:

1. `console.log(\`::add-mask::\${trimmed}\`)` doesn't escape \r/\n. If a
   value survives trim with an embedded newline (PEMs, kubeconfigs, JSON),
   the runner only registers the first line as a mask and the rest leaks.
   `core.setSecret(trimmed)` routes through @actions/core which
   percent-encodes \r/\n so the runner V2 parser decodes back to the full
   value and registers every non-empty line as a separate mask. Removes
   the load-bearing "no embedded newlines" invariant from the fix.

2. Whitespace-only sensitive values silently became "". Downstream
   truthy checks would flip from "set" to "missing" with no log. Now
   sanitizeSecret returns null in that case and callers skip the
   process.env write, surfacing a clear missing-key error instead.

Tests rewritten to assert process.env state directly — no stdout spies.
Masking correctness is delegated to @actions/core (trusted dependency).
This commit is contained in:
Colin McDonnell
2026-05-13 15:27:13 +00:00
committed by pullfrog[bot]
parent 1dc53043a6
commit d5f881e9fc
3 changed files with 143 additions and 17 deletions
+45 -13
View File
@@ -1,11 +1,40 @@
import * as core from "@actions/core";
import { log } from "./cli.ts";
import { isSensitiveEnvName } from "./secrets.ts";
function maskValue(value: string | undefined) {
if (value && typeof value === "string" && value.trim().length > 0) {
// ::add-mask::value tells GitHub Actions to mask this value in logs
console.log(`::add-mask::${value}`);
/**
* Trim surrounding whitespace from a sensitive value and register it as a
* GitHub Actions log mask. Trailing newlines from terminal-copy paste are a
* common footgun: the value travels through GH Actions logs and any tool
* that re-emits parts of it leaks the unmasked tail. Trimming canonicalises
* the value so the mask matches exactly what downstream tools will print.
*
* Masking is delegated to `core.setSecret` (not raw `console.log`) so the
* toolkit percent-encodes `\r`/`\n`; the runner V2 parser decodes them and
* registers the full value plus every non-empty line as separate masks. That
* keeps us safe for embedded-newline values (PEMs, kubeconfigs, JSON blobs)
* even though they aren't currently used.
*
* Returns the trimmed value, or `null` when the input was whitespace-only —
* callers must leave `process.env` untouched in that case so a misconfigured
* value surfaces as a clear "missing key" downstream rather than silently
* mutating to the empty string.
*/
export function sanitizeSecret(key: string, value: string): string | null {
const trimmed = value.trim();
if (trimmed.length === 0) {
log.warning(
`» ${key} is whitespace-only — leaving env var unchanged. check your secret value.`
);
return null;
}
if (trimmed !== value) {
log.warning(
`» stripped whitespace from ${key} (whitespace in secret values breaks GitHub Actions log masking)`
);
}
core.setSecret(trimmed);
return trimmed;
}
/**
@@ -15,7 +44,8 @@ function maskValue(value: string | undefined) {
* If there are conflicts (same key with different capitalizations but different values),
* logs a warning and keeps the uppercase version.
*
* Also registers sensitive values as masks in GitHub Actions.
* Also trims and masks sensitive values so accidental trailing whitespace
* doesn't defeat GitHub Actions log masking.
*/
export function normalizeEnv(): void {
const upperKeys = new Map<string, string[]>();
@@ -30,14 +60,6 @@ export function normalizeEnv(): void {
// process each group
for (const [upperKey, keys] of upperKeys) {
// if sensitive, ensure we mask the value (regardless of whether we rename it or not)
if (isSensitiveEnvName(upperKey)) {
// mask all values associated with this key group
for (const key of keys) {
maskValue(process.env[key]);
}
}
if (keys.length === 1) {
const key = keys[0];
if (key !== upperKey) {
@@ -71,4 +93,14 @@ export function normalizeEnv(): void {
// set the uppercase version
process.env[upperKey] = preferredValue;
}
// trim + mask sensitive values after case normalisation so each key is
// visited exactly once with its final, canonical value
for (const key of Object.keys(process.env)) {
if (!isSensitiveEnvName(key)) continue;
const value = process.env[key];
if (typeof value !== "string" || value.length === 0) continue;
const sanitized = sanitizeSecret(key, value);
if (sanitized !== null) process.env[key] = sanitized;
}
}