openai/codex-action
Publicmirrored from https://github.com/openai/codex-actionAvailable
src/dropSudo.ts
342lines · modecode
| 1 | import { spawn } from "node:child_process"; |
| 2 | import { promises as fs } from "node:fs"; |
| 3 | import * as path from "node:path"; |
| 4 | |
| 5 | interface ExecOptions { |
| 6 | capture?: boolean; |
| 7 | ignoreFailure?: boolean; |
| 8 | } |
| 9 | |
| 10 | interface ExecResult { |
| 11 | code: number; |
| 12 | stdout: string; |
| 13 | stderr: string; |
| 14 | } |
| 15 | |
| 16 | export interface DropSudoOptions { |
| 17 | user: string; |
| 18 | group: string; |
| 19 | rootPhase: boolean; |
| 20 | } |
| 21 | |
| 22 | const LINUX_PLATFORM = "linux"; |
| 23 | const MACOS_PLATFORM = "darwin"; |
| 24 | |
| 25 | export 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 | |
| 66 | async 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 | |
| 163 | async 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 | |
| 171 | async 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 | |
| 186 | async 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 | */ |
| 205 | async 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 | |
| 232 | async 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 | |
| 289 | async 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 | |
| 338 | interface ExecError extends Error { |
| 339 | code: number; |
| 340 | stdout: string; |
| 341 | stderr: string; |
| 342 | } |
| 343 | |