forked from pullfrog/pullfrog
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgitAuth.ts
More file actions
183 lines (160 loc) · 5.52 KB
/
Copy pathgitAuth.ts
File metadata and controls
183 lines (160 loc) · 5.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
/**
* git authentication via GIT_ASKPASS.
*
* a localhost HTTP server serves tokens via single-use UUID codes.
* each $git() call writes a unique askpass script with the server
* port+code baked into the file body — no secrets in subprocess env.
*
* 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 { 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 one-time code with the auth server, writes a
* unique askpass script with port+code baked in, spawns git with
* GIT_ASKPASS pointing to the script, and deletes the script in finally.
*
* @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 already consumed — token has been revoked");
throw new Error("git auth failed — askpass code was already consumed, 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 {
try {
unlinkSync(scriptPath);
} catch {
// script may have self-deleted already
}
}
}