mirror of
https://github.com/apple/container.git
synced 2026-09-22 23:55:34 +00:00
Add capabilities support (#1383)
Closes #1352 Containerization has had support for a bit, it was just never brought over here. It's exposed on the CLI via the classic `--cap-add` and `--cap-drop` UX.
This commit is contained in:
@@ -198,6 +198,7 @@ integration: init-block
|
||||
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIVersion || exit_code=1 ; \
|
||||
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLINetwork || exit_code=1 ; \
|
||||
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunLifecycle || exit_code=1 ; \
|
||||
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCapabilities || exit_code=1 ; \
|
||||
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIExecCommand || exit_code=1 ; \
|
||||
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLICreateCommand || exit_code=1 ; \
|
||||
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand1 || exit_code=1 ; \
|
||||
|
||||
@@ -247,6 +247,7 @@ extension Application {
|
||||
var config = ContainerConfiguration(id: Builder.builderContainerId, image: imageDesc, process: processConfig)
|
||||
config.resources = resources
|
||||
config.labels = [ResourceLabelKeys.role: ResourceRoleValues.builder]
|
||||
config.capAdd = ["ALL"]
|
||||
config.mounts = [
|
||||
.init(
|
||||
type: .tmpfs,
|
||||
|
||||
@@ -53,6 +53,10 @@ public struct ContainerConfiguration: Sendable, Codable {
|
||||
public var readOnly: Bool = false
|
||||
/// Whether to use a minimal init process inside the container.
|
||||
public var useInit: Bool = false
|
||||
/// Linux capabilities to add (normalized CAP_* strings, or "ALL").
|
||||
public var capAdd: [String] = []
|
||||
/// Linux capabilities to drop (normalized CAP_* strings, or "ALL").
|
||||
public var capDrop: [String] = []
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
@@ -73,6 +77,8 @@ public struct ContainerConfiguration: Sendable, Codable {
|
||||
case ssh
|
||||
case readOnly
|
||||
case useInit
|
||||
case capAdd
|
||||
case capDrop
|
||||
}
|
||||
|
||||
/// Create a configuration from the supplied Decoder, initializing missing
|
||||
@@ -104,6 +110,8 @@ public struct ContainerConfiguration: Sendable, Codable {
|
||||
ssh = try container.decodeIfPresent(Bool.self, forKey: .ssh) ?? false
|
||||
readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false
|
||||
useInit = try container.decodeIfPresent(Bool.self, forKey: .useInit) ?? false
|
||||
capAdd = try container.decodeIfPresent([String].self, forKey: .capAdd) ?? []
|
||||
capDrop = try container.decodeIfPresent([String].self, forKey: .capDrop) ?? []
|
||||
}
|
||||
|
||||
public struct DNSConfiguration: Sendable, Codable {
|
||||
|
||||
@@ -167,6 +167,8 @@ public struct Flags {
|
||||
|
||||
public init(
|
||||
arch: String,
|
||||
capAdd: [String],
|
||||
capDrop: [String],
|
||||
cidfile: String,
|
||||
detach: Bool,
|
||||
dns: Flags.DNS,
|
||||
@@ -193,6 +195,8 @@ public struct Flags {
|
||||
volumes: [String]
|
||||
) {
|
||||
self.arch = arch
|
||||
self.capAdd = capAdd
|
||||
self.capDrop = capDrop
|
||||
self.cidfile = cidfile
|
||||
self.detach = detach
|
||||
self.dns = dns
|
||||
@@ -222,6 +226,18 @@ public struct Flags {
|
||||
@Option(name: .shortAndLong, help: "Set arch if image can target multiple architectures")
|
||||
public var arch: String = Arch.hostArchitecture().rawValue
|
||||
|
||||
@Option(
|
||||
name: .customLong("cap-add"),
|
||||
help: .init("Add a Linux capability (e.g. CAP_NET_RAW, or ALL)", valueName: "cap")
|
||||
)
|
||||
public var capAdd: [String] = []
|
||||
|
||||
@Option(
|
||||
name: .customLong("cap-drop"),
|
||||
help: .init("Drop a Linux capability (e.g. CAP_NET_RAW, or ALL)", valueName: "cap")
|
||||
)
|
||||
public var capDrop: [String] = []
|
||||
|
||||
@Option(name: .long, help: "Write the container ID to the path provided")
|
||||
public var cidfile = ""
|
||||
|
||||
|
||||
@@ -1016,6 +1016,40 @@ public struct Parser {
|
||||
return parsed
|
||||
}
|
||||
|
||||
// MARK: Capabilities
|
||||
|
||||
/// Parse and validate --cap-add / --cap-drop arguments.
|
||||
/// Returns normalized uppercase CAP_* strings.
|
||||
public static func capabilities(capAdd: [String], capDrop: [String]) throws -> (capAdd: [String], capDrop: [String]) {
|
||||
var normalizedAdd: [String] = []
|
||||
for cap in capAdd {
|
||||
let upper = cap.uppercased()
|
||||
if upper == "ALL" {
|
||||
normalizedAdd.append("ALL")
|
||||
continue
|
||||
}
|
||||
// Validate using CapabilityName from the containerization lib
|
||||
_ = try CapabilityName(rawValue: upper)
|
||||
// Normalize to CAP_ prefixed form
|
||||
let normalized = upper.hasPrefix("CAP_") ? upper : "CAP_\(upper)"
|
||||
normalizedAdd.append(normalized)
|
||||
}
|
||||
|
||||
var normalizedDrop: [String] = []
|
||||
for cap in capDrop {
|
||||
let upper = cap.uppercased()
|
||||
if upper == "ALL" {
|
||||
normalizedDrop.append("ALL")
|
||||
continue
|
||||
}
|
||||
_ = try CapabilityName(rawValue: upper)
|
||||
let normalized = upper.hasPrefix("CAP_") ? upper : "CAP_\(upper)"
|
||||
normalizedDrop.append(normalized)
|
||||
}
|
||||
|
||||
return (normalizedAdd, normalizedDrop)
|
||||
}
|
||||
|
||||
// MARK: Miscellaneous
|
||||
|
||||
public static func parseBool(string: String) -> Bool? {
|
||||
|
||||
@@ -251,6 +251,10 @@ public struct Utility {
|
||||
config.readOnly = management.readOnly
|
||||
config.useInit = management.useInit
|
||||
|
||||
let caps = try Parser.capabilities(capAdd: management.capAdd, capDrop: management.capDrop)
|
||||
config.capAdd = caps.capAdd
|
||||
config.capDrop = caps.capDrop
|
||||
|
||||
if let runtime = management.runtime {
|
||||
config.runtimeHandler = runtime
|
||||
}
|
||||
|
||||
@@ -935,6 +935,10 @@ public actor SandboxService {
|
||||
soft: $0.soft
|
||||
)
|
||||
}
|
||||
czConfig.process.capabilities = try Self.effectiveCapabilities(
|
||||
capAdd: config.capAdd,
|
||||
capDrop: config.capDrop
|
||||
)
|
||||
switch process.user {
|
||||
case .raw(let name):
|
||||
czConfig.process.user = .init(
|
||||
@@ -981,6 +985,10 @@ public actor SandboxService {
|
||||
soft: $0.soft
|
||||
)
|
||||
}
|
||||
proc.capabilities = try Self.effectiveCapabilities(
|
||||
capAdd: containerConfig.capAdd,
|
||||
capDrop: containerConfig.capDrop
|
||||
)
|
||||
switch config.user {
|
||||
case .raw(let name):
|
||||
proc.user = .init(
|
||||
@@ -1003,6 +1011,37 @@ public actor SandboxService {
|
||||
return proc
|
||||
}
|
||||
|
||||
/// Compute effective Linux capabilities from the OCI default set, capAdd, and capDrop.
|
||||
/// Steps are processed in order, so later steps override earlier ones:
|
||||
/// 1. If "ALL" in capDrop, start empty; otherwise start from OCI defaults.
|
||||
/// 2. If "ALL" in capAdd, replace with all caps (overriding step 1); otherwise add individual caps.
|
||||
/// 3. Remove individual capDrop entries (skipping "ALL" sentinel).
|
||||
private static func effectiveCapabilities(capAdd: [String], capDrop: [String]) throws -> Containerization.LinuxCapabilities {
|
||||
// Step 1: Determine base set
|
||||
var caps: Set<CapabilityName>
|
||||
if capDrop.contains("ALL") {
|
||||
caps = []
|
||||
} else {
|
||||
caps = Set(Containerization.LinuxCapabilities.defaultOCICapabilities.effective)
|
||||
}
|
||||
|
||||
// Step 2: Process adds
|
||||
if capAdd.contains("ALL") {
|
||||
caps = Set(CapabilityName.allCases)
|
||||
} else {
|
||||
for name in capAdd {
|
||||
caps.insert(try CapabilityName(rawValue: name))
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Remove individual drops (skip "ALL" sentinel)
|
||||
for name in capDrop where name != "ALL" {
|
||||
caps.remove(try CapabilityName(rawValue: name))
|
||||
}
|
||||
|
||||
return Containerization.LinuxCapabilities(capabilities: Array(caps))
|
||||
}
|
||||
|
||||
private nonisolated func closeHandle(_ handle: Int32) throws {
|
||||
guard close(handle) == 0 else {
|
||||
guard let errCode = POSIXErrorCode(rawValue: errno) else {
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerAPIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
class TestCLIRunCapabilities: CLITest {
|
||||
func getTestName() -> String {
|
||||
Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased()
|
||||
}
|
||||
|
||||
// MARK: - Invalid capability names
|
||||
|
||||
@Test func testCapDropInvalid() throws {
|
||||
let (_, _, error, status) = try run(arguments: [
|
||||
"run", "--rm", "--cap-drop=CHWOWZERS", alpine, "ls",
|
||||
])
|
||||
#expect(status != 0, "expected non-zero exit for invalid cap-drop")
|
||||
#expect(error.contains("CHWOWZERS") || error.contains("invalid"), "expected error about invalid capability, got: \(error)")
|
||||
}
|
||||
|
||||
@Test func testCapAddInvalid() throws {
|
||||
let (_, _, error, status) = try run(arguments: [
|
||||
"run", "--rm", "--cap-add=CHWOWZERS", alpine, "ls",
|
||||
])
|
||||
#expect(status != 0, "expected non-zero exit for invalid cap-add")
|
||||
#expect(error.contains("CHWOWZERS") || error.contains("invalid"), "expected error about invalid capability, got: \(error)")
|
||||
}
|
||||
|
||||
// MARK: - Config stored correctly via inspect
|
||||
|
||||
@Test func testCapAddStored() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: ["--cap-add", "NET_ADMIN"])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let inspectResp = try inspectContainer(name)
|
||||
#expect(inspectResp.configuration.capAdd.contains("CAP_NET_ADMIN"), "expected CAP_NET_ADMIN in capAdd")
|
||||
#expect(inspectResp.configuration.capDrop.isEmpty, "expected empty capDrop")
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCapDropStored() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: ["--cap-drop", "MKNOD"])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let inspectResp = try inspectContainer(name)
|
||||
#expect(inspectResp.configuration.capDrop.contains("CAP_MKNOD"), "expected CAP_MKNOD in capDrop")
|
||||
#expect(inspectResp.configuration.capAdd.isEmpty, "expected empty capAdd")
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCapAddDropALLStored() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(
|
||||
name: name,
|
||||
args: [
|
||||
"--cap-drop", "ALL",
|
||||
"--cap-add", "SETGID",
|
||||
"--cap-add", "NET_RAW",
|
||||
])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let inspectResp = try inspectContainer(name)
|
||||
#expect(inspectResp.configuration.capDrop.contains("ALL"), "expected ALL in capDrop")
|
||||
#expect(inspectResp.configuration.capAdd.contains("CAP_SETGID"), "expected CAP_SETGID in capAdd")
|
||||
#expect(inspectResp.configuration.capAdd.contains("CAP_NET_RAW"), "expected CAP_NET_RAW in capAdd")
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCapAddALLStored() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: ["--cap-add", "ALL"])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let inspectResp = try inspectContainer(name)
|
||||
#expect(inspectResp.configuration.capAdd.contains("ALL"), "expected ALL in capAdd")
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCapDropLowerCase() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: ["--cap-drop", "mknod"])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let inspectResp = try inspectContainer(name)
|
||||
#expect(inspectResp.configuration.capDrop.contains("CAP_MKNOD"), "expected normalized CAP_MKNOD in capDrop")
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - In-container capability verification
|
||||
|
||||
@Test func testCapDropMknodCannotMknod() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: ["--cap-drop", "MKNOD"])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let (_, output, _, status) = try run(arguments: [
|
||||
"exec", name, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok",
|
||||
])
|
||||
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#expect(trimmed != "ok", "mknod should fail with CAP_MKNOD dropped")
|
||||
#expect(status != 0, "expected non-zero exit when mknod fails")
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCapDropMknodLowerCaseCannotMknod() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: ["--cap-drop", "mknod"])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let (_, output, _, status) = try run(arguments: [
|
||||
"exec", name, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok",
|
||||
])
|
||||
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#expect(trimmed != "ok", "mknod should fail with CAP_MKNOD dropped (lowercase)")
|
||||
#expect(status != 0, "expected non-zero exit when mknod fails")
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCapDropALLCannotMknod() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(
|
||||
name: name,
|
||||
args: [
|
||||
"--cap-drop", "ALL",
|
||||
"--cap-add", "SETGID",
|
||||
])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let (_, output, _, status) = try run(arguments: [
|
||||
"exec", name, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok",
|
||||
])
|
||||
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#expect(trimmed != "ok", "mknod should fail when ALL dropped and MKNOD not re-added")
|
||||
#expect(status != 0, "expected non-zero exit")
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCapDropALLAddMknodCanMknod() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(
|
||||
name: name,
|
||||
args: [
|
||||
"--cap-drop", "ALL",
|
||||
"--cap-add", "MKNOD",
|
||||
"--cap-add", "SETGID",
|
||||
])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let output = try doExec(
|
||||
name: name,
|
||||
cmd: [
|
||||
"sh", "-c", "mknod /tmp/sda b 8 0 && echo ok",
|
||||
])
|
||||
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#expect(trimmed == "ok", "mknod should succeed when MKNOD is explicitly re-added")
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCapAddALLCanDownInterface() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: ["--cap-add", "ALL"])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let output = try doExec(
|
||||
name: name,
|
||||
cmd: [
|
||||
"sh", "-c", "ip link set lo down && echo ok",
|
||||
])
|
||||
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#expect(trimmed == "ok", "ip link set should succeed with ALL caps")
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCapAddALLDropNetAdminCannotDownInterface() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(
|
||||
name: name,
|
||||
args: [
|
||||
"--cap-add", "ALL",
|
||||
"--cap-drop", "NET_ADMIN",
|
||||
])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let (_, output, _, status) = try run(arguments: [
|
||||
"exec", name, "sh", "-c", "ip link set lo down && echo ok",
|
||||
])
|
||||
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#expect(trimmed != "ok", "ip link set should fail with NET_ADMIN dropped")
|
||||
#expect(status != 0, "expected non-zero exit when NET_ADMIN is dropped")
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCapAddNetAdminCanDownInterface() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: ["--cap-add", "NET_ADMIN"])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let output = try doExec(
|
||||
name: name,
|
||||
cmd: [
|
||||
"sh", "-c", "ip link set lo down && echo ok",
|
||||
])
|
||||
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#expect(trimmed == "ok", "ip link set should succeed with NET_ADMIN added")
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Default capability behavior
|
||||
|
||||
@Test func testDefaultCapChown() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: [])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
// chown should succeed with default caps (CAP_CHOWN is in OCI defaults)
|
||||
// doExec throws on non-zero exit, so success here means CAP_CHOWN is present
|
||||
_ = try doExec(name: name, cmd: ["chown", "100", "/tmp"])
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("chown should succeed with default caps: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testNonRootUserCannotReadShadow() throws {
|
||||
// Regression test for https://github.com/apple/container/issues/1352
|
||||
// Verifies that exec as a non-root user enforces file permissions.
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: [])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
// Root should be able to read /etc/shadow
|
||||
_ = try doExec(name: name, cmd: ["cat", "/etc/shadow"])
|
||||
|
||||
// Non-root user (nobody) should NOT be able to read /etc/shadow
|
||||
let (_, _, _, status) = try run(arguments: [
|
||||
"exec", "-u", "nobody", name, "cat", "/etc/shadow",
|
||||
])
|
||||
#expect(status != 0, "non-root user should not be able to read /etc/shadow")
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCapDropChown() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: ["--cap-drop", "chown"])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let (_, _, _, status) = try run(arguments: [
|
||||
"exec", name, "chown", "100", "/tmp",
|
||||
])
|
||||
#expect(status != 0, "chown should fail when CAP_CHOWN is dropped")
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testDefaultCapFowner() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: [])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
// chmod on a file owned by root should succeed with CAP_FOWNER
|
||||
_ = try doExec(name: name, cmd: ["chmod", "777", "/etc/passwd"])
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("chmod should succeed with default caps: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Capability bitmask verification via /proc
|
||||
|
||||
@Test func testCapDropALLShowsZeroCaps() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(
|
||||
name: name,
|
||||
args: [
|
||||
"--cap-drop", "ALL",
|
||||
"--cap-add", "SETUID",
|
||||
"--cap-add", "SETGID",
|
||||
])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let output = try doExec(name: name, cmd: ["cat", "/proc/self/status"])
|
||||
// Verify CapEff is non-zero (SETUID and SETGID are granted)
|
||||
let lines = output.components(separatedBy: "\n")
|
||||
let capEffLine = lines.first { $0.hasPrefix("CapEff:") }
|
||||
#expect(capEffLine != nil, "expected CapEff line in /proc/self/status")
|
||||
|
||||
if let capEffLine {
|
||||
let value = capEffLine.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces)
|
||||
// With only SETUID (7) and SETGID (6), the bitmask should be non-zero but small
|
||||
#expect(value != "0000000000000000", "expected non-zero CapEff with SETUID+SETGID")
|
||||
|
||||
// Verify it's NOT the full capability set
|
||||
#expect(value != "000001ffffffffff", "expected restricted caps, not full set")
|
||||
}
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testNoCapFlagsUsesDefaultCaps() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: [])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let output = try doExec(name: name, cmd: ["cat", "/proc/self/status"])
|
||||
let lines = output.components(separatedBy: "\n")
|
||||
let capEffLine = lines.first { $0.hasPrefix("CapEff:") }
|
||||
#expect(capEffLine != nil, "expected CapEff line in /proc/self/status")
|
||||
|
||||
if let capEffLine {
|
||||
let value = capEffLine.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces)
|
||||
// Default OCI caps should produce a non-zero, restricted bitmask
|
||||
#expect(value != "0000000000000000", "expected non-zero CapEff with default OCI caps")
|
||||
}
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCapAddALLShowsFullCaps() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(name: name, args: ["--cap-add", "ALL"])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let output = try doExec(name: name, cmd: ["cat", "/proc/self/status"])
|
||||
let lines = output.components(separatedBy: "\n")
|
||||
let capEffLine = lines.first { $0.hasPrefix("CapEff:") }
|
||||
#expect(capEffLine != nil, "expected CapEff line in /proc/self/status")
|
||||
|
||||
if let capEffLine {
|
||||
let value = capEffLine.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces)
|
||||
// With ALL capabilities the bitmask should have all bits set for known caps
|
||||
#expect(value != "0000000000000000", "expected non-zero CapEff with ALL caps")
|
||||
}
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCapDropALLOnlyShowsZeroEffective() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
// Drop ALL with no adds - effective set should be empty
|
||||
try doLongRun(name: name, args: ["--cap-drop", "ALL"])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let output = try doExec(name: name, cmd: ["cat", "/proc/self/status"])
|
||||
let lines = output.components(separatedBy: "\n")
|
||||
let capEffLine = lines.first { $0.hasPrefix("CapEff:") }
|
||||
#expect(capEffLine != nil, "expected CapEff line in /proc/self/status")
|
||||
|
||||
if let capEffLine {
|
||||
let value = capEffLine.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces)
|
||||
#expect(value == "0000000000000000", "expected zero CapEff when ALL caps dropped, got \(value)")
|
||||
}
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Multiple cap-add and cap-drop combined
|
||||
|
||||
@Test func testMultipleCapAddDrop() throws {
|
||||
do {
|
||||
let name = getTestName()
|
||||
try doLongRun(
|
||||
name: name,
|
||||
args: [
|
||||
"--cap-add", "SYS_ADMIN",
|
||||
"--cap-add", "NET_RAW",
|
||||
"--cap-drop", "MKNOD",
|
||||
"--cap-drop", "CHOWN",
|
||||
])
|
||||
defer { try? doStop(name: name) }
|
||||
|
||||
let inspectResp = try inspectContainer(name)
|
||||
#expect(inspectResp.configuration.capAdd.count == 2)
|
||||
#expect(inspectResp.configuration.capDrop.count == 2)
|
||||
#expect(inspectResp.configuration.capAdd.contains("CAP_SYS_ADMIN"))
|
||||
#expect(inspectResp.configuration.capAdd.contains("CAP_NET_RAW"))
|
||||
#expect(inspectResp.configuration.capDrop.contains("CAP_MKNOD"))
|
||||
#expect(inspectResp.configuration.capDrop.contains("CAP_CHOWN"))
|
||||
|
||||
// Verify MKNOD is actually dropped
|
||||
let (_, output, _, _) = try run(arguments: [
|
||||
"exec", name, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok",
|
||||
])
|
||||
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
#expect(trimmed != "ok", "mknod should fail when CAP_MKNOD is dropped")
|
||||
|
||||
try doStop(name: name)
|
||||
} catch {
|
||||
Issue.record("failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1087,4 +1087,100 @@ struct ParserTest {
|
||||
#expect(result[0].hard == UInt64.max)
|
||||
}
|
||||
|
||||
// MARK: - Capabilities Parser Tests
|
||||
|
||||
@Test
|
||||
func testCapabilitiesParserEmpty() throws {
|
||||
let result = try Parser.capabilities(capAdd: [], capDrop: [])
|
||||
#expect(result.capAdd.isEmpty)
|
||||
#expect(result.capDrop.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func testCapabilitiesParserAddSingle() throws {
|
||||
let result = try Parser.capabilities(capAdd: ["CAP_NET_RAW"], capDrop: [])
|
||||
#expect(result.capAdd == ["CAP_NET_RAW"])
|
||||
#expect(result.capDrop.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func testCapabilitiesParserDropSingle() throws {
|
||||
let result = try Parser.capabilities(capAdd: [], capDrop: ["CAP_MKNOD"])
|
||||
#expect(result.capAdd.isEmpty)
|
||||
#expect(result.capDrop == ["CAP_MKNOD"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func testCapabilitiesParserWithoutPrefix() throws {
|
||||
let result = try Parser.capabilities(capAdd: ["NET_RAW"], capDrop: ["MKNOD"])
|
||||
#expect(result.capAdd == ["CAP_NET_RAW"])
|
||||
#expect(result.capDrop == ["CAP_MKNOD"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func testCapabilitiesParserCaseInsensitive() throws {
|
||||
let result = try Parser.capabilities(capAdd: ["net_raw"], capDrop: ["mknod"])
|
||||
#expect(result.capAdd == ["CAP_NET_RAW"])
|
||||
#expect(result.capDrop == ["CAP_MKNOD"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func testCapabilitiesParserLowercaseWithPrefix() throws {
|
||||
let result = try Parser.capabilities(capAdd: ["cap_net_raw"], capDrop: [])
|
||||
#expect(result.capAdd == ["CAP_NET_RAW"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func testCapabilitiesParserALL() throws {
|
||||
let result = try Parser.capabilities(capAdd: ["ALL"], capDrop: ["ALL"])
|
||||
#expect(result.capAdd == ["ALL"])
|
||||
#expect(result.capDrop == ["ALL"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func testCapabilitiesParserDropALLWithAdd() throws {
|
||||
let result = try Parser.capabilities(capAdd: ["CAP_NET_RAW", "CAP_MKNOD"], capDrop: ["ALL"])
|
||||
#expect(result.capAdd == ["CAP_NET_RAW", "CAP_MKNOD"])
|
||||
#expect(result.capDrop == ["ALL"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func testCapabilitiesParserAddALLWithDrop() throws {
|
||||
let result = try Parser.capabilities(capAdd: ["ALL"], capDrop: ["CAP_NET_ADMIN"])
|
||||
#expect(result.capAdd == ["ALL"])
|
||||
#expect(result.capDrop == ["CAP_NET_ADMIN"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func testCapabilitiesParserMultiple() throws {
|
||||
let result = try Parser.capabilities(
|
||||
capAdd: ["CAP_NET_RAW", "CAP_SYS_ADMIN"],
|
||||
capDrop: ["CAP_MKNOD", "CAP_CHOWN"]
|
||||
)
|
||||
#expect(result.capAdd.count == 2)
|
||||
#expect(result.capAdd.contains("CAP_NET_RAW"))
|
||||
#expect(result.capAdd.contains("CAP_SYS_ADMIN"))
|
||||
#expect(result.capDrop.count == 2)
|
||||
#expect(result.capDrop.contains("CAP_MKNOD"))
|
||||
#expect(result.capDrop.contains("CAP_CHOWN"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func testCapabilitiesParserInvalidAdd() throws {
|
||||
#expect {
|
||||
_ = try Parser.capabilities(capAdd: ["CHWOWZERS"], capDrop: [])
|
||||
} throws: { _ in
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func testCapabilitiesParserInvalidDrop() throws {
|
||||
#expect {
|
||||
_ = try Parser.capabilities(capAdd: [], capDrop: ["CHWOWZERS"])
|
||||
} throws: { _ in
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ container run [<options>] <image> [<arguments> ...]
|
||||
**Management Options**
|
||||
|
||||
* `-a, --arch <arch>`: Set arch if image can target multiple architectures (default: arm64)
|
||||
* `--cap-add <cap>`: Add a Linux capability (e.g. `CAP_NET_RAW`, `NET_RAW`, or `ALL`)
|
||||
* `--cap-drop <cap>`: Drop a Linux capability (e.g. `CAP_NET_RAW`, `NET_RAW`, or `ALL`)
|
||||
* `--cidfile <cidfile>`: Write the container ID to the path provided
|
||||
* `-d, --detach`: Run the container and detach from the process
|
||||
* `--dns <ip>`: DNS nameserver IP address
|
||||
@@ -204,6 +206,8 @@ container create [<options>] <image> [<arguments> ...]
|
||||
**Management Options**
|
||||
|
||||
* `-a, --arch <arch>`: Set arch if image can target multiple architectures (default: arm64)
|
||||
* `--cap-add <cap>`: Add a Linux capability (e.g. `CAP_NET_RAW`, `NET_RAW`, or `ALL`)
|
||||
* `--cap-drop <cap>`: Drop a Linux capability (e.g. `CAP_NET_RAW`, `NET_RAW`, or `ALL`)
|
||||
* `--cidfile <cidfile>`: Write the container ID to the path provided
|
||||
* `-d, --detach`: Run the container and detach from the process
|
||||
* `--dns <ip>`: DNS nameserver IP address
|
||||
|
||||
@@ -471,6 +471,51 @@ You can also output statistics in JSON format for scripting:
|
||||
- **Block I/O**: Disk bytes read and written.
|
||||
- **Pids**: Number of processes running in the container.
|
||||
|
||||
## Control Linux capabilities
|
||||
|
||||
By default, containers start with a restricted set of Linux capabilities:
|
||||
|
||||
`CAP_AUDIT_WRITE`, `CAP_CHOWN`, `CAP_DAC_OVERRIDE`, `CAP_FOWNER`, `CAP_FSETID`, `CAP_KILL`, `CAP_MKNOD`, `CAP_NET_BIND_SERVICE`, `CAP_NET_RAW`, `CAP_SETFCAP`, `CAP_SETGID`, `CAP_SETPCAP`, `CAP_SETUID`, `CAP_SYS_CHROOT`
|
||||
|
||||
You can customize the capability set using `--cap-add` and `--cap-drop` with `container run` or `container create`.
|
||||
|
||||
Capability names can be specified with or without the `CAP_` prefix, and are case-insensitive:
|
||||
|
||||
These are equivalent:
|
||||
```bash
|
||||
container run --cap-add CAP_NET_ADMIN alpine ip link set lo down
|
||||
container run --cap-add NET_ADMIN alpine ip link set lo down
|
||||
container run --cap-add net_admin alpine ip link set lo down
|
||||
```
|
||||
|
||||
To grant all capabilities:
|
||||
|
||||
```bash
|
||||
container run --cap-add ALL alpine sh -c "ip link set lo down && echo ok"
|
||||
```
|
||||
|
||||
To drop all capabilities and selectively re-add only what you need:
|
||||
|
||||
```bash
|
||||
container run --cap-drop ALL --cap-add SETUID --cap-add SETGID alpine id
|
||||
```
|
||||
|
||||
Adds are processed after drops, so `--cap-drop ALL --cap-add ALL` results in all capabilities being granted.
|
||||
|
||||
To grant all capabilities except specific ones:
|
||||
|
||||
```bash
|
||||
container run --cap-add ALL --cap-drop NET_ADMIN alpine sh
|
||||
```
|
||||
|
||||
To drop a single capability from the default set:
|
||||
|
||||
```console
|
||||
% container run --cap-drop CHOWN alpine chown 100 /tmp
|
||||
chown: /tmp: Operation not permitted
|
||||
```
|
||||
|
||||
|
||||
## Expose virtualization capabilities to a container
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
Reference in New Issue
Block a user