diff --git a/src/core/dsl/builtins/process.zig b/src/core/dsl/builtins/process.zig index 191b33ba..9a6505cc 100644 --- a/src/core/dsl/builtins/process.zig +++ b/src/core/dsl/builtins/process.zig @@ -79,6 +79,66 @@ fn buildArgv(ctx: ExecCtx, args: []const Value) BuiltinError![]const []const u8 return argv.toOwnedSlice(ctx.allocator); } +const inherited_env_keys = std.StaticStringMap(void).initComptime(.{ + .{ "LANG", {} }, + .{ "LC_ALL", {} }, + .{ "LC_CTYPE", {} }, + .{ "TERM", {} }, + .{ "SDKROOT", {} }, + .{ "MACOSX_DEPLOYMENT_TARGET", {} }, + .{ "CC", {} }, + .{ "CXX", {} }, + .{ "CFLAGS", {} }, + .{ "CPPFLAGS", {} }, + .{ "CXXFLAGS", {} }, + .{ "LDFLAGS", {} }, + .{ "MAKEFLAGS", {} }, +}); + +/// Return the environment visible to formula code. Values that describe the +/// installation are derived from the current sandbox instead of trusting the +/// parent process; only non-secret build and locale settings are inherited. +fn formulaEnvValue(ctx: ExecCtx, key: []const u8) BuiltinError!?[]const u8 { + if (std.mem.eql(u8, key, "HOME")) return ctx.malt_prefix; + if (std.mem.eql(u8, key, "PATH")) return macos_sandbox.sandbox_path; + if (std.mem.eql(u8, key, "MALT_PREFIX") or std.mem.eql(u8, key, "HOMEBREW_PREFIX")) + return ctx.malt_prefix; + if (std.mem.eql(u8, key, "HOMEBREW_CELLAR")) + return std.fmt.allocPrint(ctx.allocator, "{s}/Cellar", .{ctx.malt_prefix}) catch + return BuiltinError.OutOfMemory; + if (std.mem.eql(u8, key, "TMPDIR")) return "/tmp"; + if (inherited_env_keys.has(key)) return std.process.Environ.getPosix(ctx.environ, key); + return null; +} + +fn buildFormulaEnv(ctx: ExecCtx) BuiltinError!std.process.Environ.Map { + var map = std.process.Environ.Map.init(ctx.allocator); + errdefer map.deinit(); + for ([_][]const u8{ + "HOME", + "PATH", + "MALT_PREFIX", + "HOMEBREW_PREFIX", + "HOMEBREW_CELLAR", + "TMPDIR", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TERM", + "SDKROOT", + "MACOSX_DEPLOYMENT_TARGET", + "CC", + "CXX", + "CFLAGS", + "CPPFLAGS", + "CXXFLAGS", + "LDFLAGS", + "MAKEFLAGS", + }) |key| if (try formulaEnvValue(ctx, key)) |value| + map.put(key, value) catch return BuiltinError.OutOfMemory; + return map; +} + /// system — execute a command pub fn system(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!Value { if (args.len == 0) return Value{ .nil = {} }; @@ -100,10 +160,13 @@ pub fn system(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!Value { }; const raw = macos_sandbox.rawPassthroughEnabled(ctx.environ); + var env_map = try buildFormulaEnv(ctx); + defer env_map.deinit(); var child = std.process.spawn(ctx.io, .{ .argv = spawn_argv, .stdout = childStdioMode(ctx.suppress_child_stdout, raw), .stderr = childStdioMode(false, raw), + .environ_map = &env_map, }) catch return BuiltinError.SystemCommandFailed; const term = waitSanitized(ctx, &child, .{}) catch return BuiltinError.SystemCommandFailed; @@ -265,7 +328,7 @@ pub fn pathnameNew(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!Va pub fn envGet(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!Value { if (args.len == 0) return Value{ .nil = {} }; const key = args[0].asString(ctx.allocator) catch return Value{ .nil = {} }; - if (std.process.Environ.getPosix(ctx.environ, key)) |val| { + if (try formulaEnvValue(ctx, key)) |val| { return Value{ .string = val }; } return Value{ .nil = {} }; @@ -296,10 +359,13 @@ pub fn safePopenRead(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError! error.PathSandboxViolation => return BuiltinError.PathSandboxViolation, }; + var env_map = try buildFormulaEnv(ctx); + defer env_map.deinit(); var child = std.process.spawn(ctx.io, .{ .argv = spawn_argv, .stdout = .pipe, .stderr = .ignore, + .environ_map = &env_map, }) catch return Value{ .string = "" }; const stdout = child.stdout orelse return Value{ .string = "" }; diff --git a/tests/dsl_builtins_test.zig b/tests/dsl_builtins_test.zig index cc74a9f8..e20a79e8 100644 --- a/tests/dsl_builtins_test.zig +++ b/tests/dsl_builtins_test.zig @@ -1033,13 +1033,11 @@ test "pathnameNew wraps a string into a Pathname value" { test "envGet returns nil for absent keys, string for present keys" { var arena = std.heap.ArenaAllocator.init(testing.allocator); defer arena.deinit(); - // ctx.environ is a snapshot captured at construction; mutate the - // process env first so the snapshot reflects the test fixture. - _ = c.setenv("MALT_DSL_ENV_TEST", "yes", 1); - defer _ = c.unsetenv("MALT_DSL_ENV_TEST"); - const ctx = arenaCtx(&arena, "/tmp/malt"); - const got = try process.envGet(ctx, null, &.{.{ .string = "MALT_DSL_ENV_TEST" }}); - try testing.expectEqualStrings("yes", got.string); + const entries = [_:null]?[*:0]const u8{"LANG=malt-test-locale"}; + var ctx = arenaCtx(&arena, "/tmp/malt"); + ctx.environ = .{ .block = .{ .slice = &entries } }; + const got = try process.envGet(ctx, null, &.{.{ .string = "LANG" }}); + try testing.expectEqualStrings("malt-test-locale", got.string); const missing = try process.envGet(ctx, null, &.{.{ .string = "MALT_DSL_DOES_NOT_EXIST_XYZ" }}); try testing.expect(missing == .nil); @@ -1048,6 +1046,17 @@ test "envGet returns nil for absent keys, string for present keys" { try testing.expect(noargs == .nil); } +test "envGet refuses sensitive parent credentials" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const entries = [_:null]?[*:0]const u8{"MALT_GITHUB_TOKEN=private-token"}; + var ctx = arenaCtx(&arena, "/tmp/malt"); + ctx.environ = .{ .block = .{ .slice = &entries } }; + + const got = try process.envGet(ctx, null, &.{.{ .string = "MALT_GITHUB_TOKEN" }}); + try testing.expect(got == .nil); +} + test "envSet does not touch the real environment but returns the written value" { const ctx = mkCtx("/tmp/malt"); const v = try process.envSet(ctx, null, &.{ .{ .string = "MALT_DSL_UNSET_KEY" }, .{ .string = "val" } }); @@ -1078,6 +1087,25 @@ test "safePopenRead captures stdout and chomps trailing newline" { try testing.expectEqualStrings("hello", v.string); } +test "safePopenRead does not pass parent credentials to the child" { + try test_io.skipIfNoSubprocess(); + if (@import("builtin").os.tag != .macos) return error.SkipZigTest; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + _ = c.setenv("MALT_GITHUB_TOKEN", "private-child-token", 1); + defer _ = c.unsetenv("MALT_GITHUB_TOKEN"); + var lio = LiveIo.init(); + defer lio.deinit(); + + const got = try process.safePopenRead( + arenaCtxLive(&arena, &lio, "/tmp/malt"), + null, + &.{.{ .string = "/usr/bin/env" }}, + ); + try testing.expect(std.mem.indexOf(u8, got.string, "private-child-token") == null); +} + // --------------------------------------------------------------------------- // Version-style accessors on strings — `.major`, `.minor`, `.patch`, `.to_i` //