d5f881e9fc
* 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).
107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
import * as core from "@actions/core";
|
|
import { log } from "./cli.ts";
|
|
import { isSensitiveEnvName } from "./secrets.ts";
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Normalize environment variables to uppercase.
|
|
* This handles case-insensitive env var names (e.g., `anthropic_api_key` -> `ANTHROPIC_API_KEY`).
|
|
*
|
|
* If there are conflicts (same key with different capitalizations but different values),
|
|
* logs a warning and keeps the uppercase version.
|
|
*
|
|
* 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[]>();
|
|
|
|
// group keys by their uppercase form
|
|
for (const key of Object.keys(process.env)) {
|
|
const upper = key.toUpperCase();
|
|
const existing = upperKeys.get(upper) || [];
|
|
existing.push(key);
|
|
upperKeys.set(upper, existing);
|
|
}
|
|
|
|
// process each group
|
|
for (const [upperKey, keys] of upperKeys) {
|
|
if (keys.length === 1) {
|
|
const key = keys[0];
|
|
if (key !== upperKey) {
|
|
// single key, just needs uppercasing
|
|
process.env[upperKey] = process.env[key];
|
|
delete process.env[key];
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// multiple keys with different capitalizations
|
|
const values = keys.map((k) => process.env[k]);
|
|
const uniqueValues = new Set(values);
|
|
|
|
if (uniqueValues.size > 1) {
|
|
// conflict: different values for different capitalizations
|
|
log.warning(
|
|
`env var conflict: ${keys.join(", ")} have different values. using uppercase ${upperKey}.`
|
|
);
|
|
}
|
|
|
|
// prefer the uppercase version if it exists, otherwise use the first one
|
|
const preferredKey = keys.find((k) => k === upperKey) || keys[0];
|
|
const preferredValue = process.env[preferredKey];
|
|
|
|
// delete all variants
|
|
for (const key of keys) {
|
|
delete process.env[key];
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|