From 27712024223a0e85c697583a5c8ceb8d22fe1504 Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Thu, 2 Apr 2026 11:24:27 -0700 Subject: [PATCH] Add capabilities support 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. --- Makefile | 1 + .../Builder/BuilderStart.swift | 1 + .../Container/ContainerConfiguration.swift | 8 + .../ContainerAPIService/Client/Flags.swift | 16 + .../ContainerAPIService/Client/Parser.swift | 34 ++ .../ContainerAPIService/Client/Utility.swift | 4 + .../Server/SandboxService.swift | 38 ++ .../Run/TestCLIRunCapabilities.swift | 485 ++++++++++++++++++ .../ContainerAPIClientTests/ParserTest.swift | 96 ++++ docs/command-reference.md | 4 + docs/how-to.md | 44 ++ 11 files changed, 731 insertions(+) create mode 100644 Tests/CLITests/Subcommands/Run/TestCLIRunCapabilities.swift diff --git a/Makefile b/Makefile index 1e22c32a3..88a313c24 100644 --- a/Makefile +++ b/Makefile @@ -197,6 +197,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 ; \ diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index 8dd020d25..4794a45b0 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -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, diff --git a/Sources/ContainerResource/Container/ContainerConfiguration.swift b/Sources/ContainerResource/Container/ContainerConfiguration.swift index c4c3da6b9..ab9275c5a 100644 --- a/Sources/ContainerResource/Container/ContainerConfiguration.swift +++ b/Sources/ContainerResource/Container/ContainerConfiguration.swift @@ -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 { diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index 58be59668..f08c61654 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -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 = "" diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index e441cc883..3ecd24a31 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -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? { diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index 8ce9086be..dcc68dbb0 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -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 } diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index db8acd03c..ccec515cc 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -938,6 +938,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( @@ -984,6 +988,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( @@ -1006,6 +1014,36 @@ public actor SandboxService { return proc } + /// Compute effective Linux capabilities from the OCI default set, capAdd, and capDrop: + /// 1. If "ALL" in capDrop, start empty; otherwise start from OCI defaults. + /// 2. If "ALL" in capAdd, replace with all caps; 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 + 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 { diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunCapabilities.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunCapabilities.swift new file mode 100644 index 000000000..8dd7e2f11 --- /dev/null +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunCapabilities.swift @@ -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)") + } + } +} diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift index 7d1f6ad0c..6445fc0f9 100644 --- a/Tests/ContainerAPIClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -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 + } + } + } diff --git a/docs/command-reference.md b/docs/command-reference.md index 6bd108754..12567d202 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -43,6 +43,8 @@ container run [] [ ...] **Management Options** * `-a, --arch `: Set arch if image can target multiple architectures (default: arm64) +* `--cap-add `: Add a Linux capability (e.g. `CAP_NET_RAW`, `NET_RAW`, or `ALL`) +* `--cap-drop `: Drop a Linux capability (e.g. `CAP_NET_RAW`, `NET_RAW`, or `ALL`) * `--cidfile `: Write the container ID to the path provided * `-d, --detach`: Run the container and detach from the process * `--dns `: DNS nameserver IP address @@ -204,6 +206,8 @@ container create [] [ ...] **Management Options** * `-a, --arch `: Set arch if image can target multiple architectures (default: arm64) +* `--cap-add `: Add a Linux capability (e.g. `CAP_NET_RAW`, `NET_RAW`, or `ALL`) +* `--cap-drop `: Drop a Linux capability (e.g. `CAP_NET_RAW`, `NET_RAW`, or `ALL`) * `--cidfile `: Write the container ID to the path provided * `-d, --detach`: Run the container and detach from the process * `--dns `: DNS nameserver IP address diff --git a/docs/how-to.md b/docs/how-to.md index 12a46cbc0..29e22c551 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -471,6 +471,50 @@ 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`. + +Capability names can be specified with or without the `CAP_` prefix, and are case-insensitive: + +```console +# These are equivalent +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: + +```console +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: + +```console +container run --cap-drop ALL --cap-add SETUID --cap-add SETGID alpine id +``` + +To grant all capabilities except specific ones: + +```console +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 +``` + +The `--cap-add` and `--cap-drop` flags can also be used with `container create`. + ## Expose virtualization capabilities to a container > [!NOTE]