diff --git a/Sources/ContainerCommands/Container/ContainerExec.swift b/Sources/ContainerCommands/Container/ContainerExec.swift index f8d03553d..495a7eb0e 100644 --- a/Sources/ContainerCommands/Container/ContainerExec.swift +++ b/Sources/ContainerCommands/Container/ContainerExec.swift @@ -60,6 +60,7 @@ extension Application { config.executable = executable config.arguments = [String](self.arguments.dropFirst()) config.terminal = tty + config.noNewPrivileges = config.noNewPrivileges || self.processFlags.noNewPrivileges config.environment.append( contentsOf: try Parser.allEnv( imageEnvs: [], diff --git a/Sources/ContainerCommands/Machine/MachineRun.swift b/Sources/ContainerCommands/Machine/MachineRun.swift index dc62132ca..effc3e1de 100644 --- a/Sources/ContainerCommands/Machine/MachineRun.swift +++ b/Sources/ContainerCommands/Machine/MachineRun.swift @@ -110,6 +110,7 @@ extension Application { environment: envVars, workingDirectory: cwd, terminal: tty, + noNewPrivileges: processFlags.noNewPrivileges, user: user, supplementalGroups: additionalGroups ) diff --git a/Sources/ContainerResource/Container/ProcessConfiguration.swift b/Sources/ContainerResource/Container/ProcessConfiguration.swift index 856b0dbaa..8c43b4882 100644 --- a/Sources/ContainerResource/Container/ProcessConfiguration.swift +++ b/Sources/ContainerResource/Container/ProcessConfiguration.swift @@ -27,6 +27,8 @@ public struct ProcessConfiguration: Sendable, Codable { /// A boolean value indicating if a Terminal or PTY device should /// be attached to the Process's Standard I/O. public var terminal: Bool + /// Prevent the process and its descendants from gaining new privileges. + public var noNewPrivileges: Bool /// The User a Process should execute under. public var user: User /// Supplemental groups for the Process. @@ -76,6 +78,7 @@ public struct ProcessConfiguration: Sendable, Codable { environment: [String], workingDirectory: String = "/", terminal: Bool = false, + noNewPrivileges: Bool = false, user: User = .id(uid: 0, gid: 0), supplementalGroups: [UInt32] = [], rlimits: [Rlimit] = [] @@ -85,8 +88,36 @@ public struct ProcessConfiguration: Sendable, Codable { self.environment = environment self.workingDirectory = workingDirectory self.terminal = terminal + self.noNewPrivileges = noNewPrivileges self.user = user self.supplementalGroups = supplementalGroups self.rlimits = rlimits } + + enum CodingKeys: String, CodingKey { + case executable + case arguments + case environment + case workingDirectory + case terminal + case noNewPrivileges + case user + case supplementalGroups + case rlimits + } + + /// Create a process configuration, preserving the legacy behavior when + /// decoding a configuration that predates `noNewPrivileges`. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + executable = try container.decode(String.self, forKey: .executable) + arguments = try container.decode([String].self, forKey: .arguments) + environment = try container.decode([String].self, forKey: .environment) + workingDirectory = try container.decode(String.self, forKey: .workingDirectory) + terminal = try container.decode(Bool.self, forKey: .terminal) + noNewPrivileges = try container.decodeIfPresent(Bool.self, forKey: .noNewPrivileges) ?? false + user = try container.decode(User.self, forKey: .user) + supplementalGroups = try container.decode([UInt32].self, forKey: .supplementalGroups) + rlimits = try container.decode([Rlimit].self, forKey: .rlimits) + } } diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index 39962d436..3de23390b 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -39,6 +39,7 @@ public struct Flags { envFile: [String], gid: UInt32?, interactive: Bool, + noNewPrivileges: Bool = false, tty: Bool, uid: UInt32?, ulimits: [String], @@ -49,6 +50,7 @@ public struct Flags { self.envFile = envFile self.gid = gid self.interactive = interactive + self.noNewPrivileges = noNewPrivileges self.tty = tty self.uid = uid self.ulimits = ulimits @@ -70,6 +72,9 @@ public struct Flags { @Flag(name: .shortAndLong, help: "Keep the standard input open even if not attached") public var interactive = false + @Flag(name: .long, help: "Prevent the process from gaining new privileges") + public var noNewPrivileges = false + @Flag(name: .shortAndLong, help: "Open a TTY with the process") public var tty = false diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index a52c1499e..37d2ed2bb 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -323,6 +323,7 @@ public struct Parser { environment: envvars, workingDirectory: workingDir, terminal: processFlags.tty, + noNewPrivileges: processFlags.noNewPrivileges, user: user, supplementalGroups: additionalGroups, rlimits: rlimits diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 948a65603..877ed8ed9 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -1170,6 +1170,7 @@ public actor RuntimeService { } czConfig.process.terminal = process.terminal + czConfig.process.noNewPrivileges = process.noNewPrivileges czConfig.process.workingDirectory = process.workingDirectory try czConfig.process.rlimits = process.rlimits.map { LinuxRLimit( @@ -1220,6 +1221,9 @@ public actor RuntimeService { } proc.terminal = config.terminal + // An exec request may opt into no-new-privileges, but it cannot weaken + // the policy persisted for the container at creation time. + proc.noNewPrivileges = config.noNewPrivileges || containerConfig.initProcess.noNewPrivileges proc.workingDirectory = config.workingDirectory try proc.rlimits = config.rlimits.map { LinuxRLimit( diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift index a0c3751d3..b1dc212d3 100644 --- a/Tests/ContainerAPIClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -974,6 +974,38 @@ struct ParserTest { #expect(result.workingDirectory == "/bin") } + @Test + func testProcessNoNewPrivilegesFlag() throws { + let processFlags = try Flags.Process.parse(["--no-new-privileges"]) + let managementFlags = try Flags.Management.parse([]) + + let result = try Parser.process( + arguments: ["/bin/true"], + processFlags: processFlags, + managementFlags: managementFlags, + config: nil + ) + + #expect(processFlags.noNewPrivileges) + #expect(result.noNewPrivileges) + } + + @Test + func testProcessNoNewPrivilegesDefaultsToFalse() throws { + let processFlags = try Flags.Process.parse([]) + let managementFlags = try Flags.Management.parse([]) + + let result = try Parser.process( + arguments: ["/bin/true"], + processFlags: processFlags, + managementFlags: managementFlags, + config: nil + ) + + #expect(!processFlags.noNewPrivileges) + #expect(!result.noNewPrivileges) + } + @Test func testUlimitParserSoftAndHard() throws { let result = try Parser.rlimits(["nofile=1024:2048"]) diff --git a/Tests/ContainerResourceTests/ProcessConfigurationTests.swift b/Tests/ContainerResourceTests/ProcessConfigurationTests.swift new file mode 100644 index 000000000..96a9e6fee --- /dev/null +++ b/Tests/ContainerResourceTests/ProcessConfigurationTests.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerResource + +struct ProcessConfigurationTests { + private func makeProcess(noNewPrivileges: Bool = false) -> ProcessConfiguration { + ProcessConfiguration( + executable: "/bin/true", + arguments: [], + environment: [], + noNewPrivileges: noNewPrivileges + ) + } + + @Test + func roundTripsNoNewPrivileges() throws { + let data = try JSONEncoder().encode(makeProcess(noNewPrivileges: true)) + let decoded = try JSONDecoder().decode(ProcessConfiguration.self, from: data) + #expect(decoded.noNewPrivileges) + } + + @Test + func decodesMissingNoNewPrivilegesAsFalse() throws { + let data = try JSONEncoder().encode(makeProcess(noNewPrivileges: true)) + var object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + object.removeValue(forKey: "noNewPrivileges") + let legacyData = try JSONSerialization.data(withJSONObject: object) + + let decoded = try JSONDecoder().decode(ProcessConfiguration.self, from: legacyData) + #expect(!decoded.noNewPrivileges) + } +} diff --git a/Tests/IntegrationTests/Run/TestCLIRunSecurityPaths.swift b/Tests/IntegrationTests/Run/TestCLIRunSecurityPaths.swift index 91ec5662b..8865683fd 100644 --- a/Tests/IntegrationTests/Run/TestCLIRunSecurityPaths.swift +++ b/Tests/IntegrationTests/Run/TestCLIRunSecurityPaths.swift @@ -14,6 +14,8 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerAPIClient +import ContainerResource import ContainerTestSupport import Containerization import Foundation @@ -46,6 +48,105 @@ struct TestCLIRunSecurityPaths { value.trimmingCharacters(in: .whitespacesAndNewlines) } + private func directExecNoNewPrivilegesStatus( + containerId: String, noNewPrivileges: Bool + ) async throws -> String { + let pipe = Pipe() + let config = ProcessConfiguration( + executable: "sh", + arguments: ["-c", "awk '/^NoNewPrivs:/ { print $2 }' /proc/self/status"], + environment: [], + noNewPrivileges: noNewPrivileges + ) + let process = try await ContainerClient().createProcess( + containerId: containerId, + processId: UUID().uuidString.lowercased(), + configuration: config, + stdio: [nil, pipe.fileHandleForWriting, pipe.fileHandleForWriting] + ) + try await process.start() + pipe.fileHandleForWriting.closeFile() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + try? pipe.fileHandleForReading.close() + _ = try await process.wait() + return trimmed(String(data: data, encoding: .utf8) ?? "") + } + + // MARK: - No new privileges + + @Test func testNoNewPrivilegesAppliesToInitAndExecProcesses() async throws { + try await ContainerFixture.with { f in + let initResult = try f.run([ + "run", "--rm", "--no-new-privileges", alpine.rawValue, + "sh", "-c", "awk '/^NoNewPrivs:/ { print $2 }' /proc/self/status", + ]).check() + #expect(trimmed(initResult.output) == "1") + + let c = "\(f.testID)-c" + try await f.doLongRun( + name: c, + image: alpine.rawValue, + args: ["--no-new-privileges"], + autoRemove: false, + waitUntilRunning: true + ) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.initProcess.noNewPrivileges) + + let execStatus = try f.doExec( + c, + cmd: ["sh", "-c", "awk '/^NoNewPrivs:/ { print $2 }' /proc/self/status"] + ) + #expect(trimmed(execStatus) == "1") + } + } + + @Test func testNoNewPrivilegesCannotBeWeakenedThroughTheContainerAPI() async throws { + try await ContainerFixture.with { f in + let hardened = "\(f.testID)-hardened" + try await f.doLongRun( + name: hardened, + image: alpine.rawValue, + args: ["--no-new-privileges"], + autoRemove: false, + waitUntilRunning: true + ) + f.addCleanup { + try? f.doStop(hardened) + try? f.doRemove(hardened) + } + + let hardenedStatus = try await directExecNoNewPrivilegesStatus( + containerId: hardened, + noNewPrivileges: false + ) + #expect(hardenedStatus == "1") + + let ordinary = "\(f.testID)-ordinary" + try await f.doLongRun( + name: ordinary, + image: alpine.rawValue, + autoRemove: false, + waitUntilRunning: true + ) + f.addCleanup { + try? f.doStop(ordinary) + try? f.doRemove(ordinary) + } + + let ordinaryStatus = try await directExecNoNewPrivilegesStatus( + containerId: ordinary, + noNewPrivileges: false + ) + #expect(ordinaryStatus == "0") + } + } + // MARK: - Invalid paths @Test func testRelativePathsRejected() async throws { diff --git a/docs/command-reference.md b/docs/command-reference.md index d75ab5970..a8f3d4e30 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -30,6 +30,7 @@ container run [] [ ...] * `--env-file `: Read in a file of environment variables (key=value format, ignores # comments and blank lines) * `--gid `: Set the group ID for the process * `-i, --interactive`: Keep the standard input open even if not attached +* `--no-new-privileges`: Prevent the process and its descendants from gaining new privileges * `-t, --tty`: Open a TTY with the process * `-u, --user `: Set the user for the process (format: name|uid[:gid]) * `--uid `: Set the user ID for the process @@ -206,6 +207,7 @@ container create [] [ ...] * `--env-file `: Read in a file of environment variables (key=value format, ignores # comments and blank lines) * `--gid `: Set the group ID for the process * `-i, --interactive`: Keep the standard input open even if not attached +* `--no-new-privileges`: Prevent the process and its descendants from gaining new privileges * `-t, --tty`: Open a TTY with the process * `-u, --user `: Set the user for the process (format: name|uid[:gid]) * `--uid `: Set the user ID for the process @@ -361,7 +363,7 @@ Executes a command inside a running container. It uses the same process flags as **Usage** ```bash -container exec [--detach] [--env ...] [--env-file ...] [--gid ] [--interactive] [--tty] [--user ] [--uid ] [--workdir ] [--debug] ... +container exec [--detach] [--env ...] [--env-file ...] [--gid ] [--interactive] [--no-new-privileges] [--tty] [--user ] [--uid ] [--workdir ] [--debug] ... ``` **Arguments** @@ -379,6 +381,7 @@ container exec [--detach] [--env ...] [--env-file ...] [--gid < * `--env-file `: Read in a file of environment variables (key=value format, ignores # comments and blank lines) * `--gid `: Set the group ID for the process * `-i, --interactive`: Keep the standard input open even if not attached +* `--no-new-privileges`: Prevent the process and its descendants from gaining new privileges * `-t, --tty`: Open a TTY with the process * `-u, --user `: Set the user for the process (format: name|uid[:gid]) * `--uid `: Set the user ID for the process @@ -1141,6 +1144,7 @@ container machine run [] [] [ ...] * `--env-file `: Read in a file of environment variables (key=value format, ignores # comments and blank lines) * `--gid `: Set the group ID for the process * `-i, --interactive`: Keep the standard input open even if not attached +* `--no-new-privileges`: Prevent the process and its descendants from gaining new privileges * `-t, --tty`: Open a TTY with the process * `-u, --user `: Set the user for the process (format: name|uid[:gid]) * `--uid `: Set the user ID for the process