diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 78fcd73..1664ea8 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -25,6 +25,41 @@ import Foundation /// Represents a single service definition within the `services` section. +/// One `ulimits:` entry. +/// +/// Compose accepts either a single value (`nofile: 65535`) or a soft/hard pair +/// (`nofile: {soft: 20000, hard: 40000}`). `container run --ulimit` takes +/// `=[:]`, so both forms are normalised to that string here. +/// +/// Decoding is deliberately throwing: an entry this cannot understand fails the +/// whole file rather than nilling the map, which would drop the sibling entries +/// with it and give no sign that anything was lost. +private struct UlimitValue: Decodable { + let flagValue: String + + private enum CodingKeys: String, CodingKey { + case soft, hard + } + + init(from decoder: any Decoder) throws { + if let single = try? decoder.singleValueContainer() { + if let intValue = try? single.decode(Int.self) { + flagValue = "\(intValue)" + return + } + if let stringValue = try? single.decode(String.self) { + flagValue = stringValue + return + } + } + + let keyed = try decoder.container(keyedBy: CodingKeys.self) + let soft = try keyed.decode(Int.self, forKey: .soft) + let hard = try keyed.decode(Int.self, forKey: .hard) + flagValue = "\(soft):\(hard)" + } +} + public struct Service: Codable, Hashable { /// Docker image name public let image: String? @@ -92,6 +127,30 @@ public struct Service: Codable, Hashable { /// Mount container's root filesystem as read-only public let read_only: Bool? + /// Linux capabilities to add, e.g. `NET_BIND_SERVICE` + public let cap_add: [String]? + + /// Linux capabilities to drop, e.g. `ALL` + public let cap_drop: [String]? + + /// Size of `/dev/shm`, e.g. `256m` + public let shm_size: String? + + /// Compose `init:` — run an init process that reaps zombies. + /// Named `runInit` because `init` is a Swift keyword; the wire name is + /// restored by the `init` CodingKey below. + public let runInit: Bool? + + /// Resource limits, e.g. `["nofile": "65535"]` + public let ulimits: [String: String]? + + /// tmpfs mounts, Compose list form: `["/run:noexec,nosuid", "/tmp"]` + public let tmpfs: [String]? + + /// Compose `network_mode`. Parsed so it can be reported; `container run` + /// has no equivalent, see `ComposeUp.unsupportedOptionWarnings`. + public let network_mode: String? + /// Working directory inside the container public let working_dir: String? @@ -133,7 +192,8 @@ public struct Service: Codable, Hashable { enum CodingKeys: String, CodingKey { case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, container_name, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform, - mem_limit, extra_hosts, profiles + mem_limit, extra_hosts, profiles, cap_add, cap_drop, shm_size, ulimits, tmpfs, network_mode + case runInit = "init" } /// Public memberwise initializer for testing @@ -159,6 +219,13 @@ public struct Service: Codable, Hashable { entrypoint: [String]? = nil, privileged: Bool? = nil, read_only: Bool? = nil, + cap_add: [String]? = nil, + cap_drop: [String]? = nil, + shm_size: String? = nil, + runInit: Bool? = nil, + ulimits: [String: String]? = nil, + tmpfs: [String]? = nil, + network_mode: String? = nil, working_dir: String? = nil, platform: String? = nil, configs: [ServiceConfig]? = nil, @@ -191,6 +258,13 @@ public struct Service: Codable, Hashable { self.entrypoint = entrypoint self.privileged = privileged self.read_only = read_only + self.cap_add = cap_add + self.cap_drop = cap_drop + self.shm_size = shm_size + self.runInit = runInit + self.ulimits = ulimits + self.tmpfs = tmpfs + self.network_mode = network_mode self.working_dir = working_dir self.platform = platform self.configs = configs @@ -315,6 +389,24 @@ public struct Service: Codable, Hashable { privileged = try container.decodeIfPresent(Bool.self, forKey: .privileged) read_only = try container.decodeIfPresent(Bool.self, forKey: .read_only) + cap_add = try container.decodeIfPresent([String].self, forKey: .cap_add) + cap_drop = try container.decodeIfPresent([String].self, forKey: .cap_drop) + shm_size = try container.decodeIfPresent(String.self, forKey: .shm_size) + runInit = try container.decodeIfPresent(Bool.self, forKey: .runInit) + ulimits = try container + .decodeIfPresent([String: UlimitValue].self, forKey: .ulimits)? + .mapValues(\.flagValue) + + // List form is the common one; a bare string is also legal. Anything else + // throws rather than silently becoming nil. + if !container.contains(.tmpfs) { + tmpfs = nil + } else if let list = try? container.decode([String].self, forKey: .tmpfs) { + tmpfs = list + } else { + tmpfs = [try container.decode(String.self, forKey: .tmpfs)] + } + network_mode = try container.decodeIfPresent(String.self, forKey: .network_mode) working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir) configs = try container.decodeIfPresent([ServiceConfig].self, forKey: .configs) secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index d42efd7..983bc9a 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -486,6 +486,101 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return (value, false) } + /// Maps the container-hardening compose keys to `container run` flags. + /// + /// Extracted as a pure function so the mapping is directly testable: the + /// surrounding argument assembly has no test seam, which is how + /// `healthcheck.timeout` stayed parsed-but-unapplied. + /// + /// Capabilities are emitted drop-then-add for readability only. `container` + /// collects `--cap-add` and `--cap-drop` into two separate arrays and then + /// computes the effective set (drop-ALL clears the base, adds are applied, + /// individual drops are removed), so the order they appear in on the command + /// line carries no meaning. + static func hardeningRunArgs(for service: Service, environment: [String: String] = [:]) -> [String] { + var args: [String] = [] + + for capability in service.cap_drop ?? [] { + args.append(contentsOf: ["--cap-drop", resolveVariable(capability, with: environment)]) + } + for capability in service.cap_add ?? [] { + args.append(contentsOf: ["--cap-add", resolveVariable(capability, with: environment)]) + } + + if let shmSize = service.shm_size { + args.append(contentsOf: ["--shm-size", resolveVariable(shmSize, with: environment)]) + } + if service.runInit == true { + args.append("--init") + } + for name in (service.ulimits ?? [:]).keys.sorted() { + guard let value = service.ulimits?[name] else { continue } + args.append(contentsOf: ["--ulimit", "\(name)=\(resolveVariable(value, with: environment))"]) + } + + for entry in service.tmpfs ?? [] { + let (target, options) = Self.splitTmpfsEntry(resolveVariable(entry, with: environment)) + var spec = "type=tmpfs,target=\(target)" + for option in options where option.hasPrefix("mode=") || option.hasPrefix("size=") { + spec += ",\(option)" + } + args.append(contentsOf: ["--mount", spec]) + } + + return args + } + + /// Splits a Compose tmpfs entry into its target path and its option list. + static func splitTmpfsEntry(_ entry: String) -> (target: String, options: [String]) { + let parts = entry.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + let target = String(parts[0]).trimmingCharacters(in: .whitespaces) + guard parts.count == 2 else { return (target, []) } + // `/run:noexec, mode=0755` is legal Compose; without the trim the second + // option would not match its prefix and would be dropped as unsupported. + let options = parts[1] + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + return (target, options) + } + + /// Compose options this tool parses but `container run` cannot express. + /// + /// Returned rather than printed so the mapping stays testable, and reported + /// rather than dropped silently — a silent drop is the failure mode this + /// change set exists to remove. + static func unsupportedOptionWarnings( + for service: Service, + serviceName: String, + environment: [String: String] = [:] + ) -> [String] { + var warnings: [String] = [] + + for entry in service.tmpfs ?? [] { + let (target, options) = Self.splitTmpfsEntry(resolveVariable(entry, with: environment)) + let dropped = options.filter { !$0.hasPrefix("mode=") && !$0.hasPrefix("size=") } + guard !dropped.isEmpty else { continue } + + warnings.append( + "Note: Service '\(serviceName)' tmpfs '\(target)': `container run` accepts only target, mode and size; dropped \(dropped.joined(separator: ","))." + ) + + if dropped.contains(where: { $0.hasPrefix("uid=") || $0.hasPrefix("gid=") }) { + warnings.append( + "Warning: Service '\(serviceName)' tmpfs '\(target)' requested uid/gid ownership, which `container run` cannot express. The mount will be owned by root, so a non-root container cannot write to it unless the mode is world-writable." + ) + } + } + + if let mode = service.network_mode { + warnings.append( + "Note: Service '\(serviceName)' sets network_mode: \(mode). `container run` has no equivalent; the container will join the default network." + ) + } + + return warnings + } + static func validateStoppedServiceExitCode(_ exitCode: Int32, serviceName: String) throws { guard exitCode == 0 else { throw ComposeError.containerRunFailed(serviceName, exitCode) @@ -1070,6 +1165,17 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append("--read-only") } + runCommandArgs.append( + contentsOf: Self.hardeningRunArgs(for: service, environment: environmentVariables) + ) + for warning in Self.unsupportedOptionWarnings( + for: service, + serviceName: serviceName, + environment: environmentVariables + ) { + print(warning) + } + // Add resource limits. // `mem_limit` is the top-level shorthand; `deploy.resources.limits.memory` is // the structured form. Both map to `container run --memory`. `mem_limit` takes diff --git a/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift new file mode 100644 index 0000000..1783657 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/HardeningArgsTests.swift @@ -0,0 +1,235 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// 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 Testing +import Foundation +@testable import Yams +@testable import ContainerComposeCore + +@Suite("Hardening Run Args") +struct HardeningArgsTests { + + private func service(_ yaml: String) throws -> Service { + try YAMLDecoder().decode(Service.self, from: yaml) + } + + @Test("cap_drop and cap_add parse") + func capabilitiesParse() throws { + let svc = try service(""" + image: alpine + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + """) + #expect(svc.cap_drop == ["ALL"]) + #expect(svc.cap_add == ["NET_BIND_SERVICE"]) + } + + /// Order is deliberately not asserted: `container` collects the two flags + /// into separate arrays and computes the effective capability set, so the + /// command-line order carries no meaning. What matters is that every + /// declared capability reaches the right flag. + @Test("every declared capability reaches its flag") + func capabilitiesAreEmitted() throws { + let svc = Service( + image: "alpine", + cap_add: ["NET_BIND_SERVICE", "CHOWN"], + cap_drop: ["ALL"] + ) + let args = ComposeUp.hardeningRunArgs(for: svc) + #expect(args.count == 6) + #expect(zip(args, args.dropFirst()).contains { $0 == "--cap-drop" && $1 == "ALL" }) + #expect(zip(args, args.dropFirst()).contains { $0 == "--cap-add" && $1 == "NET_BIND_SERVICE" }) + #expect(zip(args, args.dropFirst()).contains { $0 == "--cap-add" && $1 == "CHOWN" }) + } + + + @Test("shm_size, init and ulimits parse") + func scalarHardeningParse() throws { + let svc = try service(""" + image: alpine + shm_size: 256m + init: true + ulimits: + nofile: 65535 + """) + #expect(svc.shm_size == "256m") + #expect(svc.runInit == true) + #expect(svc.ulimits?["nofile"] == "65535") + } + + @Test("shm_size, init and ulimits emit flags") + func scalarHardeningArgs() throws { + let svc = Service(image: "alpine", shm_size: "256m", runInit: true, ulimits: ["nofile": "65535"]) + let args = ComposeUp.hardeningRunArgs(for: svc) + #expect(args.contains("--init")) + #expect(args.firstIndex(of: "--shm-size").map { args[$0 + 1] } == "256m") + #expect(args.firstIndex(of: "--ulimit").map { args[$0 + 1] } == "nofile=65535") + } + + @Test("init false emits no flag") + func initFalseEmitsNothing() throws { + let svc = Service(image: "alpine", runInit: false) + #expect(ComposeUp.hardeningRunArgs(for: svc).contains("--init") == false) + } + + @Test("tmpfs list form parses") + func tmpfsParses() throws { + let svc = try service(""" + image: alpine + tmpfs: + - /run:noexec,nosuid + - /tmp + """) + #expect(svc.tmpfs == ["/run:noexec,nosuid", "/tmp"]) + } + + @Test("tmpfs maps to --mount type=tmpfs, never --tmpfs") + func tmpfsUsesMountFlag() throws { + let svc = Service(image: "alpine", tmpfs: ["/tmp"]) + let args = ComposeUp.hardeningRunArgs(for: svc) + #expect(args.contains("--tmpfs") == false) + #expect(args.firstIndex(of: "--mount").map { args[$0 + 1] } == "type=tmpfs,target=/tmp") + } + + @Test("tmpfs mode is carried through, unsupported options are not") + func tmpfsCarriesModeOnly() throws { + let svc = Service(image: "alpine", tmpfs: ["/run/postgresql:noexec,nosuid,uid=70,gid=70,mode=0755"]) + let args = ComposeUp.hardeningRunArgs(for: svc) + let spec = try #require(args.firstIndex(of: "--mount").map { args[$0 + 1] }) + #expect(spec.contains("target=/run/postgresql")) + #expect(spec.contains("mode=0755")) + #expect(spec.contains("uid=") == false) + #expect(spec.contains("noexec") == false) + } + + @Test("dropped tmpfs options are reported, and uid/gid gets its own warning") + func tmpfsDroppedOptionsAreReported() throws { + let svc = Service(image: "alpine", tmpfs: ["/run/postgresql:noexec,nosuid,uid=70,gid=70,mode=0755"]) + let warnings = ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "patroni1") + #expect(warnings.contains { $0.contains("noexec") && $0.contains("/run/postgresql") }) + #expect(warnings.contains { $0.contains("non-root") }) + } + + @Test("tmpfs with no options produces no warning") + func tmpfsNoOptionsNoWarning() throws { + let svc = Service(image: "alpine", tmpfs: ["/tmp"]) + #expect(ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "web").isEmpty) + } + + @Test("network_mode parses") + func networkModeParses() throws { + let svc = try service(""" + image: alpine + network_mode: none + """) + #expect(svc.network_mode == "none") + } + + @Test("network_mode emits no run args") + func networkModeEmitsNoArgs() throws { + let svc = Service(image: "alpine", network_mode: "none") + #expect(ComposeUp.hardeningRunArgs(for: svc).isEmpty) + } + + @Test("network_mode is reported as unsupported") + func networkModeIsReported() throws { + let svc = Service(image: "alpine", network_mode: "none") + let warnings = ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "etcd-init") + #expect(warnings.contains { $0.contains("network_mode") && $0.contains("etcd-init") }) + } + + @Test("absent network_mode produces no warning") + func absentNetworkModeNoWarning() throws { + let svc = Service(image: "alpine") + #expect(ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "web").isEmpty) + } + + @Test("ulimits soft/hard long form maps to soft:hard") + func ulimitsLongForm() throws { + let svc = try service(""" + image: alpine + ulimits: + nproc: 65535 + nofile: + soft: 20000 + hard: 40000 + """) + #expect(svc.ulimits?["nproc"] == "65535") + #expect(svc.ulimits?["nofile"] == "20000:40000") + + let args = ComposeUp.hardeningRunArgs(for: svc) + #expect(zip(args, args.dropFirst()).contains { $0 == "--ulimit" && $1 == "nofile=20000:40000" }) + #expect(zip(args, args.dropFirst()).contains { $0 == "--ulimit" && $1 == "nproc=65535" }) + } + + @Test("an unreadable ulimits entry fails the file instead of nilling the map") + func ulimitsMalformedThrows() throws { + #expect(throws: (any Error).self) { + try YAMLDecoder().decode(Service.self, from: """ + image: alpine + ulimits: + nofile: + soft: 20000 + """) + } + } + + @Test("an unreadable tmpfs value fails the file instead of nilling it") + func tmpfsMalformedThrows() throws { + #expect(throws: (any Error).self) { + try YAMLDecoder().decode(Service.self, from: """ + image: alpine + tmpfs: + run: true + """) + } + } + + @Test("whitespace after a comma does not lose the option") + func tmpfsOptionWhitespace() throws { + let svc = Service(image: "alpine", tmpfs: ["/run:noexec, mode=0755"]) + let args = ComposeUp.hardeningRunArgs(for: svc) + let spec = try #require(args.firstIndex(of: "--mount").map { args[$0 + 1] }) + #expect(spec.contains("mode=0755")) + #expect(ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "web") + .contains { $0.contains("noexec") }) + } + + @Test("variables are interpolated in the hardening keys") + func hardeningKeysInterpolate() throws { + let env = ["RUNTIME_DIR": "/run/app", "SHM": "512m", "NOFILE": "65535"] + let svc = Service( + image: "alpine", + shm_size: "${SHM}", + ulimits: ["nofile": "${NOFILE}"], + tmpfs: ["${RUNTIME_DIR}:mode=0755"] + ) + let args = ComposeUp.hardeningRunArgs(for: svc, environment: env) + #expect(args.firstIndex(of: "--shm-size").map { args[$0 + 1] } == "512m") + #expect(zip(args, args.dropFirst()).contains { $0 == "--ulimit" && $1 == "nofile=65535" }) + let spec = try #require(args.firstIndex(of: "--mount").map { args[$0 + 1] }) + #expect(spec.contains("target=/run/app")) + #expect(spec.contains("${") == false) + } + + @Test("no hardening keys yields no args") + func emptyYieldsNothing() throws { + let svc = Service(image: "alpine") + #expect(ComposeUp.hardeningRunArgs(for: svc).isEmpty) + } +} diff --git a/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift b/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift new file mode 100644 index 0000000..0d3c3ac --- /dev/null +++ b/Tests/Container-Compose-StaticTests/HardeningComposeIntegrationTests.swift @@ -0,0 +1,114 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// 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 Testing +import Foundation +@testable import Yams +@testable import ContainerComposeCore + +/// The per-key tests in `HardeningArgsTests` build a `Service` directly. These +/// exercise the same keys through a whole compose document that uses a YAML +/// anchor and merge keys, which is how hardening settings are usually shared +/// across services in real files. A parser that silently failed to resolve +/// `<<:` would pass every per-key test and fail here. +@Suite("Hardening Keys Through A Compose Document") +struct HardeningComposeIntegrationTests { + + private static let yaml = """ + x-common: &common + cap_drop: + - ALL + init: true + read_only: true + + services: + web: + <<: *common + image: nginx:alpine + cap_add: + - NET_BIND_SERVICE + tmpfs: + - /run:noexec,nosuid,uid=70,gid=70,mode=0755 + db: + <<: *common + image: postgres:16 + shm_size: 256m + ulimits: + nofile: 65535 + fixer: + <<: *common + image: alpine:3 + network_mode: none + """ + + private func compose() throws -> DockerCompose { + try YAMLDecoder().decode(DockerCompose.self, from: Self.yaml) + } + + private func service(_ name: String) throws -> Service { + let svc = try compose().services[name] + return try #require(svc ?? nil) + } + + @Test("merge keys carry hardening settings to every service") + func mergeKeysResolve() throws { + let compose = try compose() + #expect(compose.services.count == 3) + for name in ["web", "db", "fixer"] { + let svc = try service(name) + #expect(svc.cap_drop == ["ALL"], "\(name) did not inherit cap_drop") + #expect(svc.runInit == true, "\(name) did not inherit init") + #expect(svc.read_only == true, "\(name) did not inherit read_only") + } + } + + @Test("inherited and per-service capabilities both reach the command line") + func capabilitiesCombine() throws { + let args = ComposeUp.hardeningRunArgs(for: try service("web")) + #expect(zip(args, args.dropFirst()).contains { $0 == "--cap-drop" && $1 == "ALL" }) + #expect(zip(args, args.dropFirst()).contains { $0 == "--cap-add" && $1 == "NET_BIND_SERVICE" }) + } + + @Test("tmpfs keeps mode, drops what container run cannot express, and says so") + func tmpfsThroughDocument() throws { + let svc = try service("web") + let args = ComposeUp.hardeningRunArgs(for: svc) + let spec = try #require(args.firstIndex(of: "--mount").map { args[$0 + 1] }) + #expect(spec.contains("target=/run")) + #expect(spec.contains("mode=0755")) + #expect(spec.contains("uid=") == false) + + let warnings = ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "web") + #expect(warnings.contains { $0.contains("noexec") }) + #expect(warnings.contains { $0.contains("non-root") }) + } + + @Test("scalar hardening keys survive the document round trip") + func scalarsThroughDocument() throws { + let args = ComposeUp.hardeningRunArgs(for: try service("db")) + #expect(args.firstIndex(of: "--shm-size").map { args[$0 + 1] } == "256m") + #expect(args.firstIndex(of: "--ulimit").map { args[$0 + 1] } == "nofile=65535") + #expect(args.contains("--init")) + } + + @Test("network_mode produces a warning and no run args") + func networkModeThroughDocument() throws { + let svc = try service("fixer") + #expect(ComposeUp.hardeningRunArgs(for: svc).contains("--network") == false) + #expect(ComposeUp.unsupportedOptionWarnings(for: svc, serviceName: "fixer") + .contains { $0.contains("network_mode") }) + } +}