utils/secrets.ts
Rama main · réplica de lectura del repositorio original.
/**
* Secret detection and env filtering utilities
*
* subprocess env filtering: default-deny allowlist model.
* only vars in the safe set or user allowlist are passed to child processes.
*
* log redaction: SENSITIVE_PATTERNS are used to identify secret values
* for redaction in logs and GHA masking (independent of subprocess filtering).
*/
// --- log redaction (unchanged, independent of subprocess filtering) ---
// patterns for sensitive env var names (used by normalizeEnv)
export const SENSITIVE_PATTERNS = [
/_KEY$/i,
/_SECRET$/i,
/_TOKEN$/i,
/_PASSWORD$/i,
/_CREDENTIAL$/i,
/_HEADERS$/i,
];
export function isSensitiveEnvName(key: string): boolean {
return SENSITIVE_PATTERNS.some((p) => p.test(key));
}
// --- subprocess env filtering ---
// prefixes whose vars are safe to pass through (runner metadata, workflow context).
// GITHUB_TOKEN/GH_TOKEN match the GITHUB_ prefix but are still filtered by default because
// isSensitiveEnvName() catches the _TOKEN suffix; users can opt in explicitly via the allowlist.
const SAFE_ENV_PREFIXES = ["GITHUB_", "RUNNER_", "JAVA_HOME_", "GOROOT_"];
// exact var names safe to pass through (system + runner image toolchain)
const SAFE_ENV_NAMES = new Set([
// system
"CI",
"HOME",
"LANG",
"LOGNAME",
"PATH",
"SHELL",
"SHLVL",
"TERM",
"TMPDIR",
"TZ",
"USER",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"DEBIAN_FRONTEND",
// a runner's shared object store: without it, the commits it holds read as `bad object HEAD`
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
// runner image toolchain
"ACCEPT_EULA",
"AGENT_TOOLSDIRECTORY",
"ANDROID_HOME",
"ANDROID_NDK",
"ANDROID_NDK_HOME",
"ANDROID_NDK_LATEST_HOME",
"ANDROID_NDK_ROOT",
"ANDROID_SDK_ROOT",
"ANT_HOME",
"AZURE_EXTENSION_DIR",
"BOOTSTRAP_HASKELL_NONINTERACTIVE",
"CHROME_BIN",
"CHROMEWEBDRIVER",
"CONDA",
"DOTNET_MULTILEVEL_LOOKUP",
"DOTNET_NOLOGO",
"DOTNET_SKIP_FIRST_TIME_EXPERIENCE",
"EDGEWEBDRIVER",
"GECKOWEBDRIVER",
"GHCUP_INSTALL_BASE_PREFIX",
"GRADLE_HOME",
"JAVA_HOME",
"HOMEBREW_CLEANUP_PERIODIC_FULL_DAYS",
"HOMEBREW_NO_AUTO_UPDATE",
"ImageOS",
"ImageVersion",
"NVM_DIR",
"PIPX_BIN_DIR",
"PIPX_HOME",
"PSModulePath",
"SELENIUM_JAR_PATH",
"SGX_AESM_ADDR",
"SWIFT_PATH",
"VCPKG_INSTALLATION_ROOT",
]);
let _userAllowlist: Set<string> | null = null;
export function setEnvAllowlist(raw: string): void {
const names = raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
_userAllowlist = new Set(names);
}
function isSafeEnvVar(key: string): boolean {
if (SAFE_ENV_NAMES.has(key)) return true;
return SAFE_ENV_PREFIXES.some((p) => key.startsWith(p));
}
/**
* GitHub Actions workflow-command FILE paths. Writing to any of them mutates the
* job itself — `$GITHUB_ENV` injects env into later steps, `$GITHUB_PATH`
* prepends to PATH, `$GITHUB_OUTPUT` forges step outputs. They pass
* `isSafeEnvVar` on the `GITHUB_`/`RUNNER_` prefix and carry no secret, so
* they were never the allowlist's concern — but a package manager running a
* third-party `postinstall` is arbitrary code execution OUTSIDE the shell tool's
* mount namespace (see wiki/security.md), which is exactly the boundary those
* files let it cross. Handing it the exact paths is strictly worse than making
* it guess them.
*/
const WORKFLOW_COMMAND_ENV_NAMES = [
"GITHUB_ENV",
"GITHUB_PATH",
"GITHUB_OUTPUT",
"GITHUB_STATE",
"GITHUB_STEP_SUMMARY",
// dropping the five paths above but keeping RUNNER_TEMP would be theatre:
// the file-command files live in `$RUNNER_TEMP/_runner_file_commands/`, so the
// directory is one `ls` away. nothing in the install path reads it (only our
// own leak-surface wipe in `setup.ts`, which runs in the parent), and package
// managers use TMPDIR.
"RUNNER_TEMP",
];
/**
* `filterEnv()` minus the workflow-command file paths. For subprocesses that
* execute third-party code we do not sandbox — the dependency installers.
*/
export function filterEnvForUntrustedCode(): Record<string, string> {
const env = filterEnv();
for (const name of WORKFLOW_COMMAND_ENV_NAMES) delete env[name];
return env;
}
/** filter env vars using default-deny allowlist: safe set + user allowlist */
export function filterEnv(): Record<string, string> {
const filtered: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value === undefined) continue;
const userAllowed = _userAllowlist?.has(key) ?? false;
if (isSensitiveEnvName(key) && !userAllowed) continue;
if (isSafeEnvVar(key) || userAllowed) {
filtered[key] = value;
}
}
return filtered;
}
export type EnvMode = "restricted" | "inherit" | Record<string, string>;
/**
* resolve env mode to actual env object
* - "restricted" (default): filterEnv() — only safe set + user allowlist
* - "inherit": full process.env
* - object: custom env merged with restricted base
*/
export function resolveEnv(mode: EnvMode | undefined): Record<string, string | undefined> {
if (mode === "inherit") {
return process.env;
}
if (mode === "restricted" || mode === undefined) {
return filterEnv();
}
// custom env object - merge with restricted base
return { ...filterEnv(), ...mode };
}