Files
Colin McDonnell 585a5d21cc fix(askpass): scope code + script lifetime to one $git() call (#841)
* fix(askpass): scope code + script lifetime to one $git() call, not first password prompt

LFS pre-push (and any auth-bound sibling subprocess) consumed the single-use
code AND triggered the script self-delete, so git's own push call then hit
`fatal: cannot exec '/tmp/pullfrog-…/askpass-…js'` and our server treated
the legitimate retry as tamper, revoking the installation token. Observed
on nteract/nteract#2987 (LFS repo).

`gitAuthServer` codes are now `active` until `$git()`'s finally calls
`revoke()`; the script no longer self-deletes (finally already unlinks).
Replay after revoke still trips 409 + token revocation, which is the
realistic exfiltration vector we care about.

* fix(askpass): drop wall-clock TTL on active codes

Copilot review on #841 noticed the 5-minute CODE_TTL_MS still applied to
active codes, which would re-introduce the original LFS failure mode at a
different boundary: a large LFS push lasting >5min would hit a 404 mid-
call. $git() uses `activityTimeout: 0` precisely because git fetch/push
can take arbitrarily long, so any wall-clock TTL on active codes is wrong.

Active codes now live until revoke() is called (in $git()'s finally) or
the auth server is closed. Revoked codes keep their 60s replay trap.

* docs(askpass): purge stale single-use vocabulary; align error message

Pullfrog review on #841 surfaced four doc-drift sites that still described
the pre-PR single-use model:

- wiki/security.md — 3 references (overview prose, bullets, threat-mitigation table)
- action/utils/gitAuth.ts — file-level JSDoc + the 409 error message
- wiki/askpass.md — error message quoted in the tamper-evident section
- action/utils/gitAuthServer.ts — per-prompt invocation comment was ambiguous

All updated to match the active|revoked vocabulary; error message is now
"askpass code was replayed after revoke, token revoked".
2026-05-26 18:27:40 +00:00

248 lines
8.5 KiB
TypeScript

/**
* git authentication via GIT_ASKPASS.
*
* a localhost HTTP server serves tokens via UUID codes whose lifetime is
* bounded by the parent $git() invocation: register() makes the code active,
* the script (and any sibling subprocess — e.g. git-lfs pre-push) can fetch
* the token any number of times, and $git()'s finally calls revoke() to
* close the window. each $git() call writes a unique askpass script with
* the server port+code baked into the file body — no secrets in subprocess
* env. a replay of a revoked code trips a 409 and revokes the underlying
* github installation token.
*
* see wiki/askpass.md for full security documentation.
*/
import { execSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync, realpathSync, unlinkSync } from "node:fs";
import { log } from "./cli.ts";
import type { GitAuthServer } from "./gitAuthServer.ts";
import { filterEnv } from "./secrets.ts";
import { $ } from "./shell.ts";
import { spawn } from "./subprocess.ts";
type SafeGitSubcommand = "fetch" | "push";
type GitAuthOptions = {
token: string;
cwd?: string;
};
type GitResult = {
stdout: string;
stderr: string;
};
// --- git binary resolution and tamper detection ---
type GitBinaryInfo = {
path: string;
sha256: string;
};
let gitBinary: GitBinaryInfo | undefined;
function hashFile(path: string): string {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
/**
* resolve and fingerprint the git binary. must be called once at startup
* (in main()) before any agent code runs, so the path and hash reflect
* the untampered binary.
*
* resolves symlinks via realpath so the hash is of the actual binary.
* a malicious agent with sudo could replace the binary later, which is
* caught by verifyGitBinary() before each authenticated call.
*/
export function resolveGit(): void {
const whichPath = execSync("which git", { encoding: "utf-8" }).trim();
const resolvedPath = realpathSync(whichPath);
const sha256 = hashFile(resolvedPath);
gitBinary = { path: resolvedPath, sha256 };
log.debug(`» git binary: ${resolvedPath} (sha256: ${sha256.slice(0, 12)}...)`);
}
function verifyGitBinary(): string {
if (!gitBinary) {
throw new Error("git binary not initialized — call resolveGit() at startup");
}
const currentHash = hashFile(gitBinary.path);
if (currentHash !== gitBinary.sha256) {
throw new Error(
`git binary tampered: expected sha256 ${gitBinary.sha256}, got ${currentHash}. ` +
`path: ${gitBinary.path}`
);
}
return gitBinary.path;
}
// --- auth server ---
let authServer: GitAuthServer | undefined;
export function setGitAuthServer(server: GitAuthServer): void {
authServer = server;
}
/**
* execute authenticated git command via ASKPASS.
*
* subcommand is restricted to "fetch" | "push" — operations that talk to
* a remote and need credentials. working-tree operations (checkout, merge)
* use $() from shell.ts which has no token.
*
* per call: registers a code with the auth server (valid for the lifetime
* of this invocation), writes a unique askpass script with port+code baked
* in, spawns git with GIT_ASKPASS pointing to the script. on completion,
* revokes the code and deletes the script in finally. multiple sibling
* askpass calls within one invocation (e.g. git itself + git-lfs pre-push)
* all see a valid code; replay attempts after finally trip a 409 and the
* server revokes the underlying github token as a tamper signal.
*
* @example
* await $git("fetch", ["origin", "main"], { token });
* await $git("push", ["-u", "origin", "feature"], { token });
*/
export async function $git(
subcommand: SafeGitSubcommand,
args: string[],
options: GitAuthOptions
): Promise<GitResult> {
const gitPath = verifyGitBinary();
if (!authServer) {
throw new Error("git auth server not initialized — call setGitAuthServer() at startup");
}
const cwd = options.cwd ?? process.cwd();
const code = authServer.register(options.token);
const scriptPath = authServer.writeAskpassScript(code);
// -c flags override local .git/config — defense-in-depth against
// agent-set config that could spawn subprocesses before ASKPASS runs
const fullArgs = [
"-c",
"core.fsmonitor=false",
"-c",
"credential.helper=",
"-c",
"protocol.file.allow=never",
"-c",
"core.sshCommand=ssh",
subcommand,
...args,
];
log.debug(`git ${fullArgs.join(" ")}`);
try {
const result = await spawn({
cmd: gitPath,
args: fullArgs,
cwd,
env: {
...filterEnv(),
GIT_ASKPASS: scriptPath,
GIT_TERMINAL_PROMPT: "0",
// blocks env-based git config injection from outer processes.
// GIT_CONFIG_COUNT=0 blocks the newer KEY_n/VALUE_n mechanism.
// GIT_CONFIG_PARAMETERS="" clears the legacy quoted-list mechanism.
// both are needed — they are independent systems.
GIT_CONFIG_COUNT: "0",
GIT_CONFIG_PARAMETERS: "",
},
activityTimeout: 0,
});
if (result.stderr.includes("askpass-compromised")) {
log.info("askpass code was replayed after revoke — token has been revoked");
throw new Error("git auth failed — askpass code was replayed after revoke, token revoked");
}
if (result.exitCode !== 0) {
const stderr = result.stderr.trim();
const stdout = result.stdout.trim();
// stderr is the primary channel for git diagnostics, but in rare cases
// (e.g. some HTTPS smart-protocol failures) the only useful detail is
// on stdout — without it the agent / operator sees an empty error.
// include exit code so we can distinguish e.g. signal-killed (1 with
// empty output) from a genuine git-level rejection.
const detail =
stderr && stdout
? `${stderr}\n--- stdout ---\n${stdout}`
: stderr || stdout || "(no output)";
const message = `git ${subcommand} failed (exit ${result.exitCode}): ${detail}`;
log.info(message);
throw new Error(message);
}
return {
stdout: result.stdout.trim(),
stderr: result.stderr.trim(),
};
} finally {
authServer.revoke(code);
try {
unlinkSync(scriptPath);
} catch {
// script may already be gone (e.g. tmpdir cleanup raced us)
}
}
}
/**
* shallow-clone unreachable: when an existing local depth is too shallow for
* git to traverse to the requested ref's ancestry, the remote walk fails with
* one of these wordings (git emits the full OID via oid_to_hex, so the bound
* is 40 for SHA-1 or 64 for SHA-256). detecting both lets a single deepen
* retry recover before the error reaches the agent — see issue #564 for the
* original `git_fetch` precedent and #656 for the `checkout_pr` follow-up.
*/
export const SHALLOW_UNREACHABLE_PATTERNS: RegExp[] = [
/Could not read [a-f0-9]{40,64}/,
/remote did not send all necessary objects/,
];
/**
* large enough to clear the merge base on most real-world PRs without
* downloading the full history; matches the fallback used by
* `checkoutPrBranch` when the GitHub compare API is unavailable.
*/
export const DEEPEN_RETRY_DEPTH = 1000;
/**
* authenticated `git fetch` that recovers from shallow-unreachable errors
* by retrying once with `--deepen=1000`. callers pass the same args they
* would to `$git("fetch", ...)`; on shallow-unreachable failures in a
* shallow repo, the second attempt prepends `--deepen=N` and strips any
* caller-supplied `--depth=` (the two flags are mutually exclusive, and
* the caller's depth is what got us into this mess).
*
* non-shallow-unreachable errors and non-shallow repos rethrow unchanged,
* so this is safe to wrap any fetch without changing fast-path behavior.
*/
export async function $gitFetchWithDeepen(
args: string[],
options: GitAuthOptions,
label?: string
): Promise<GitResult> {
try {
return await $git("fetch", args, options);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const isShallowUnreachable = SHALLOW_UNREACHABLE_PATTERNS.some((p) => p.test(msg));
if (!isShallowUnreachable) throw err;
const isShallow =
$("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
if (!isShallow) throw err;
log.info(
`» ${label ?? "git fetch"} hit shallow-unreachable error, retrying with --deepen=${DEEPEN_RETRY_DEPTH}`
);
const retryArgs = args.filter((a) => !a.startsWith("--depth="));
return await $git("fetch", [`--deepen=${DEEPEN_RETRY_DEPTH}`, ...retryArgs], options);
}
}