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:
Danny Canter
2026-04-07 17:09:56 -07:00
committed by GitHub
parent e37dcc19a5
commit a557ce80a5
11 changed files with 733 additions and 0 deletions
+1
View File
@@ -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
}
}
}
+4
View File
@@ -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
+45
View File
@@ -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]