Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/ContainerCommands/Container/ContainerExec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
1 change: 1 addition & 0 deletions Sources/ContainerCommands/Machine/MachineRun.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ extension Application {
environment: envVars,
workingDirectory: cwd,
terminal: tty,
noNewPrivileges: processFlags.noNewPrivileges,
user: user,
supplementalGroups: additionalGroups
)
Expand Down
31 changes: 31 additions & 0 deletions Sources/ContainerResource/Container/ProcessConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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] = []
Expand All @@ -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)
}
}
5 changes: 5 additions & 0 deletions Sources/Services/ContainerAPIService/Client/Flags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public struct Flags {
envFile: [String],
gid: UInt32?,
interactive: Bool,
noNewPrivileges: Bool = false,
tty: Bool,
uid: UInt32?,
ulimits: [String],
Expand All @@ -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
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions Sources/Services/ContainerAPIService/Client/Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ public struct Parser {
environment: envvars,
workingDirectory: workingDir,
terminal: processFlags.tty,
noNewPrivileges: processFlags.noNewPrivileges,
user: user,
supplementalGroups: additionalGroups,
rlimits: rlimits
Expand Down
4 changes: 4 additions & 0 deletions Sources/Services/RuntimeLinux/Server/RuntimeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions Tests/ContainerAPIClientTests/ParserTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
49 changes: 49 additions & 0 deletions Tests/ContainerResourceTests/ProcessConfigurationTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
101 changes: 101 additions & 0 deletions Tests/IntegrationTests/Run/TestCLIRunSecurityPaths.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerAPIClient
import ContainerResource
import ContainerTestSupport
import Containerization
import Foundation
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ container run [<options>] <image> [<arguments> ...]
* `--env-file <env-file>`: Read in a file of environment variables (key=value format, ignores # comments and blank lines)
* `--gid <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 <user>`: Set the user for the process (format: name|uid[:gid])
* `--uid <uid>`: Set the user ID for the process
Expand Down Expand Up @@ -206,6 +207,7 @@ container create [<options>] <image> [<arguments> ...]
* `--env-file <env-file>`: Read in a file of environment variables (key=value format, ignores # comments and blank lines)
* `--gid <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 <user>`: Set the user for the process (format: name|uid[:gid])
* `--uid <uid>`: Set the user ID for the process
Expand Down Expand Up @@ -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> ...] [--env-file <env-file> ...] [--gid <gid>] [--interactive] [--tty] [--user <user>] [--uid <uid>] [--workdir <dir>] [--debug] <container-id> <arguments> ...
container exec [--detach] [--env <env> ...] [--env-file <env-file> ...] [--gid <gid>] [--interactive] [--no-new-privileges] [--tty] [--user <user>] [--uid <uid>] [--workdir <dir>] [--debug] <container-id> <arguments> ...
```

**Arguments**
Expand All @@ -379,6 +381,7 @@ container exec [--detach] [--env <env> ...] [--env-file <env-file> ...] [--gid <
* `--env-file <env-file>`: Read in a file of environment variables (key=value format, ignores # comments and blank lines)
* `--gid <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 <user>`: Set the user for the process (format: name|uid[:gid])
* `--uid <uid>`: Set the user ID for the process
Expand Down Expand Up @@ -1141,6 +1144,7 @@ container machine run [<options>] [<executable>] [<arguments> ...]
* `--env-file <env-file>`: Read in a file of environment variables (key=value format, ignores # comments and blank lines)
* `--gid <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 <user>`: Set the user for the process (format: name|uid[:gid])
* `--uid <uid>`: Set the user ID for the process
Expand Down