openai/codex-action

Public

mirrored from https://github.com/openai/codex-actionAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
main

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

src/dropSudo.ts

342lines · modecode

1import { spawn } from "node:child_process";
2import { promises as fs } from "node:fs";
3import * as path from "node:path";
4
5interface ExecOptions {
6 capture?: boolean;
7 ignoreFailure?: boolean;
8}
9
10interface ExecResult {
11 code: number;
12 stdout: string;
13 stderr: string;
14}
15
16export interface DropSudoOptions {
17 user: string;
18 group: string;
19 rootPhase: boolean;
20}
21
22const LINUX_PLATFORM = "linux";
23const MACOS_PLATFORM = "darwin";
24
25export async function dropSudo(options: DropSudoOptions): Promise<void> {
26 const platform = process.platform;
27 if (![LINUX_PLATFORM, MACOS_PLATFORM].includes(platform)) {
28 throw new Error(
29 `Unsupported OS for drop-sudo safety strategy: ${platform}`
30 );
31 }
32
33 const { rootPhase } = options;
34 if (rootPhase) {
35 await dropSudoWithPrivileges(options);
36 return;
37 }
38
39 await ensurePasswordlessSudo();
40 // `sudo -K` invalidates cached credentials but exits non-zero when no ticket
41 // exists yet. Ignore that failure so fresh runners don't blow up.
42 await execCommand("sudo", ["-K"], { ignoreFailure: true });
43
44 const execArgs = [...process.execArgv];
45 const scriptPath = process.argv[1];
46 // Re-enter this command under sudo so the privilege-dropping work happens in a
47 // single place regardless of the host platform.
48 await execCommand("sudo", [
49 "-n",
50 "node",
51 ...execArgs,
52 scriptPath,
53 "drop-sudo",
54 "--root-phase",
55 "--user",
56 options.user,
57 "--group",
58 options.group,
59 ]);
60
61 // Invalidate the sudo ticket again; ignore failures for the same reason as
62 // above (some environments return an error when no timestamp exists).
63 await execCommand("sudo", ["-K"], { ignoreFailure: true });
64}
65
66async function dropSudoWithPrivileges(options: DropSudoOptions): Promise<void> {
67 if (typeof process.getuid === "function" && process.getuid() !== 0) {
68 throw new Error("drop-sudo root phase must run as root.");
69 }
70
71 let changed = false;
72
73 switch (process.platform) {
74 case LINUX_PLATFORM: {
75 if (await isUserInGroup(options.user, options.group)) {
76 if (await commandExists("deluser")) {
77 await execCommand("deluser", [options.user, options.group]);
78 console.log(
79 `Used 'deluser ${options.user} ${options.group}' to drop sudo privilege.`
80 );
81 } else if (await commandExists("gpasswd")) {
82 await execCommand("gpasswd", ["-d", options.user, options.group]);
83 console.log(
84 `Used 'gpasswd -d ${options.user} ${options.group}' to drop sudo privilege.`
85 );
86 } else {
87 throw new Error("Neither deluser nor gpasswd available.");
88 }
89 changed = true;
90 } else {
91 console.log(
92 `${options.user} is not a member of the ${options.group} group.`
93 );
94 }
95 break;
96 }
97 case MACOS_PLATFORM: {
98 if (await isUserInGroup(options.user, options.group)) {
99 await execCommand("dseditgroup", [
100 "-o",
101 "edit",
102 "-d",
103 options.user,
104 "-t",
105 "user",
106 options.group,
107 ]);
108 console.log(
109 `Used 'dseditgroup -o edit -d ${options.user} -t user ${options.group}' to drop sudo privilege.`
110 );
111 changed = true;
112 } else {
113 console.log(
114 `${options.user} is not a member of the ${options.group} group.`
115 );
116 }
117 break;
118 }
119 default: {
120 throw new Error(
121 `Unsupported OS for drop-sudo safety strategy: ${process.platform}`
122 );
123 }
124 }
125
126 const messages = await removeUserFromSudoersD(options.user);
127 if (messages.length > 0) {
128 for (const message of messages) {
129 console.log(message);
130 }
131 changed = true;
132 } else {
133 console.log(
134 `No ${options.user} entries found in /etc/sudoers.d requiring changes.`
135 );
136 }
137
138 const sudoersMessage = await stripUserEntriesFromFile(
139 "/etc/sudoers",
140 options.user
141 );
142 if (sudoersMessage) {
143 console.log(sudoersMessage);
144 changed = true;
145 } else {
146 console.log(
147 `No ${options.user} entries found in /etc/sudoers requiring changes.`
148 );
149 }
150
151 if (!changed) {
152 console.log(`${options.user} already lacks sudo privileges.`);
153 }
154
155 const groupsAfter = await execCommand("id", ["-Gn", options.user], {
156 capture: true,
157 });
158 console.log(
159 `Groups for ${options.user} after cleanup: ${groupsAfter.stdout.trim()}`
160 );
161}
162
163async function ensurePasswordlessSudo(): Promise<void> {
164 try {
165 await execCommand("sudo", ["-n", "true"], { capture: true });
166 } catch (error) {
167 throw new Error("Unexpected: passwordless sudo not available.");
168 }
169}
170
171async function isUserInGroup(user: string, group: string): Promise<boolean> {
172 const result = await execCommand("id", ["-nG", user], {
173 capture: true,
174 ignoreFailure: true,
175 });
176 if (result.code !== 0) {
177 return false;
178 }
179 const groups = result.stdout
180 .trim()
181 .split(/\s+/)
182 .filter((value) => value.length > 0);
183 return groups.includes(group);
184}
185
186async function commandExists(binary: string): Promise<boolean> {
187 const result = await execCommand("sh", ["-c", `command -v ${binary}`], {
188 capture: true,
189 ignoreFailure: true,
190 });
191 return result.code === 0;
192}
193
194/**
195 * Strips non-comment entries granting sudo to `user` across `/etc/sudoers.d`
196 * files.
197 *
198 * Strategy:
199 * - enumerate regular files under `/etc/sudoers.d`
200 * - remove lines whose first token matches the target user while keeping
201 * comments/blank lines intact
202 * - rewrite files in-place with original newline style and permissions
203 * - report which files were changed so callers can surface useful logs
204 */
205async function removeUserFromSudoersD(user: string): Promise<Array<string>> {
206 const sudoersDir = "/etc/sudoers.d";
207 let entries: Array<string> = [];
208 try {
209 const dirEntries = await fs.readdir(sudoersDir, { withFileTypes: true });
210 entries = dirEntries
211 .filter((dirent) => dirent.isFile())
212 .map((dirent) => path.join(sudoersDir, dirent.name));
213 } catch (error) {
214 if ((error as NodeJS.ErrnoException).code === "ENOENT") {
215 return [];
216 }
217 throw error;
218 }
219
220 const messages: Array<string> = [];
221
222 for (const entryPath of entries) {
223 const message = await stripUserEntriesFromFile(entryPath, user);
224 if (message) {
225 messages.push(message);
226 }
227 }
228
229 return messages;
230}
231
232async function stripUserEntriesFromFile(
233 filePath: string,
234 user: string
235): Promise<string | null> {
236 let stats;
237 let original: string;
238 try {
239 stats = await fs.stat(filePath);
240 original = await fs.readFile(filePath, "utf8");
241 } catch {
242 return null;
243 }
244
245 const newline = original.includes("\r\n") ? "\r\n" : "\n";
246 const endsWithNewline =
247 original.endsWith("\n") || original.endsWith("\r\n");
248 const rawLines = original.split(/\r?\n/);
249 if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") {
250 rawLines.pop();
251 }
252
253 const filteredLines: Array<string> = [];
254 let changed = false;
255
256 for (const line of rawLines) {
257 const trimmedLeading = line.trimStart();
258 if (trimmedLeading.startsWith("#")) {
259 filteredLines.push(line);
260 continue;
261 }
262 if (trimmedLeading.length === 0) {
263 filteredLines.push(line);
264 continue;
265 }
266 const tokens = trimmedLeading.split(/\s+/);
267 if (tokens[0] === user) {
268 changed = true;
269 continue;
270 }
271 filteredLines.push(line);
272 }
273
274 if (!changed) {
275 return null;
276 }
277
278 const rebuilt = filteredLines.join(newline) + (endsWithNewline ? newline : "");
279 try {
280 await fs.writeFile(filePath, rebuilt, "utf8");
281 await fs.chmod(filePath, stats.mode & 0o777);
282 } catch {
283 return null;
284 }
285
286 return `Removed ${user} entry from ${filePath}`;
287}
288
289async function execCommand(
290 command: string,
291 args: Array<string>,
292 options: ExecOptions = {}
293): Promise<ExecResult> {
294 const capture = options.capture ?? false;
295 const child = spawn(command, args, {
296 stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
297 });
298
299 let stdout = "";
300 let stderr = "";
301
302 if (capture && child.stdout) {
303 child.stdout.setEncoding("utf8");
304 child.stdout.on("data", (chunk) => {
305 stdout += chunk;
306 });
307 }
308
309 if (capture && child.stderr) {
310 child.stderr.setEncoding("utf8");
311 child.stderr.on("data", (chunk) => {
312 stderr += chunk;
313 });
314 }
315
316 return await new Promise<ExecResult>((resolve, reject) => {
317 child.on("error", (error) => {
318 reject(error);
319 });
320
321 child.on("close", (code) => {
322 const exitCode = code ?? 0;
323 if (exitCode !== 0 && !options.ignoreFailure) {
324 const error = new Error(
325 `Command failed: ${command} ${args.join(" ")} (exit code ${exitCode})`
326 );
327 (error as ExecError).code = exitCode;
328 (error as ExecError).stdout = stdout;
329 (error as ExecError).stderr = stderr;
330 reject(error);
331 return;
332 }
333 resolve({ code: exitCode, stdout, stderr });
334 });
335 });
336}
337
338interface ExecError extends Error {
339 code: number;
340 stdout: string;
341 stderr: string;
342}
343