From e24eeb9aff08eae37661b74dfd4fac86040e8262 Mon Sep 17 00:00:00 2001 From: Aditya Ramani Date: Tue, 4 Aug 2026 17:51:06 -0700 Subject: [PATCH] Add `--read-only-path` and `--masked-path` option to container run / create --- .../ContainerAPIService/Client/Flags.swift | 24 +++ .../ContainerAPIService/Client/Parser.swift | 54 ++++++ .../ContainerAPIService/Client/Utility.swift | 2 + .../ContainerAPIClientTests/ParserTest.swift | 147 +++++++++++++++ .../Run/TestCLIRunSecurityPaths.swift | 169 ++++++++++++++++++ docs/command-reference.md | 4 + docs/how-to.md | 38 ++++ 7 files changed, 438 insertions(+) create mode 100644 Tests/IntegrationTests/Run/TestCLIRunSecurityPaths.swift diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index 980c7c1b2..a1111ddb4 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -178,6 +178,7 @@ public struct Flags { kernel: String?, kernelArgs: [String], labels: [String], + maskedPaths: [String], mounts: [String], name: String?, networks: [String], @@ -186,6 +187,7 @@ public struct Flags { publishPorts: [String], publishSockets: [String], readOnly: Bool, + readonlyPaths: [String], remove: Bool, rosetta: Bool, runtime: String?, @@ -208,6 +210,7 @@ public struct Flags { self.kernel = kernel self.kernelArgs = kernelArgs self.labels = labels + self.maskedPaths = maskedPaths self.mounts = mounts self.name = name self.networks = networks @@ -216,6 +219,7 @@ public struct Flags { self.publishPorts = publishPorts self.publishSockets = publishSockets self.readOnly = readOnly + self.readonlyPaths = readonlyPaths self.remove = remove self.rosetta = rosetta self.runtime = runtime @@ -291,6 +295,16 @@ public struct Flags { @Option(name: [.short, .customLong("label")], help: "Add a key=value label to the container") public var labels: [String] = [] + /// EXPERIMENTAL: The flag is subject to change. + @Option( + name: .customLong("masked-path"), + help: .init( + "[EXPERIMENTAL] Hide a path inside the container, in addition to the runtime defaults (or NONE to clear prior values and the defaults)", + valueName: "path" + ) + ) + public var maskedPaths: [String] = [] + @Option(name: .customLong("mount"), help: "Add a mount to the container (format: type=<>,source=<>,target=<>,readonly)") public var mounts: [String] = [] @@ -330,6 +344,16 @@ public struct Flags { @Flag(name: .long, help: "Mount the container's root filesystem as read-only") public var readOnly = false + /// EXPERIMENTAL: The flag is subject to change. + @Option( + name: .customLong("read-only-path"), + help: .init( + "[EXPERIMENTAL] Mark a path inside the container read-only, in addition to the runtime defaults (or NONE to clear prior values and the defaults)", + valueName: "path" + ) + ) + public var readonlyPaths: [String] = [] + @Flag(name: [.customLong("rm"), .long], help: "Remove the container after it stops") public var remove = false diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index e4516d7fd..559796bca 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -1056,6 +1056,60 @@ public struct Parser { return (normalizedAdd, normalizedDrop) } + // MARK: Security paths + + /// Sentinel that clears all previously accumulated paths, including the runtime defaults. + private static let pathResetSentinel = "NONE" + + /// Parse and validate --masked-path arguments. + /// + /// Values are processed in order on top of the runtime default set, so + /// `--masked-path /foo` yields the defaults plus `/foo`. The `NONE` sentinel + /// clears everything accumulated so far, including the defaults. A nil result + /// means the flag was not supplied and the runtime defaults apply unchanged. + public static func maskedPaths(_ values: [String]) throws -> [String]? { + try pathOverrides(values, defaults: LinuxContainer.defaultMaskedPaths(), flagName: "masked-path") + } + + /// Parse and validate --read-only-path arguments. Ordering, the `NONE` + /// sentinel, and the nil result carry the same meaning as ``maskedPaths(_:)``. + public static func readonlyPaths(_ values: [String]) throws -> [String]? { + try pathOverrides(values, defaults: LinuxContainer.defaultReadonlyPaths(), flagName: "read-only-path") + } + + /// Accumulate absolute paths on top of `defaults`, honoring the `NONE` reset + /// sentinel and dropping duplicates while preserving first-occurrence order. + private static func pathOverrides(_ values: [String], defaults: [String], flagName: String) throws -> [String]? { + guard !values.isEmpty else { + return nil + } + var paths = defaults + var seen = Set(defaults) + for value in values { + let trimmed = value.trimmingCharacters(in: .whitespaces) + if trimmed.uppercased() == pathResetSentinel { + paths = [] + seen = [] + continue + } + guard trimmed.hasPrefix("/") else { + throw ContainerizationError( + .invalidArgument, + message: "invalid path '\(value)' for --\(flagName): path must be absolute, or the \(pathResetSentinel) sentinel" + ) + } + // Strip trailing slashes, preserving the root path itself. + var normalized = trimmed + while normalized.count > 1 && normalized.hasSuffix("/") { + normalized.removeLast() + } + if seen.insert(normalized).inserted { + paths.append(normalized) + } + } + return paths + } + // 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 163a1db14..f6329c35a 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -255,6 +255,8 @@ public struct Utility { let caps = try Parser.capabilities(capAdd: management.capAdd, capDrop: management.capDrop) config.capAdd = caps.capAdd config.capDrop = caps.capDrop + config.maskedPaths = try Parser.maskedPaths(management.maskedPaths) + config.readonlyPaths = try Parser.readonlyPaths(management.readonlyPaths) config.stopSignal = imageConfig?.stopSignal if let runtime = management.runtime { diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift index 3e39698bd..7684ff936 100644 --- a/Tests/ContainerAPIClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import Containerization import ContainerizationError import ContainerizationExtras import Foundation @@ -1196,6 +1197,152 @@ struct ParserTest { } } + // MARK: - Masked Paths Parser Tests + + @Test + func testMaskedPathsParserEmpty() throws { + #expect(try Parser.maskedPaths([]) == nil) + } + + @Test + func testMaskedPathsParserAppendsToDefaults() throws { + let result = try Parser.maskedPaths(["/run/secrets"]) + #expect(result == LinuxContainer.defaultMaskedPaths() + ["/run/secrets"]) + } + + @Test + func testMaskedPathsParserResetSentinelOnly() throws { + #expect(try Parser.maskedPaths(["NONE"]) == []) + } + + @Test + func testMaskedPathsParserResetSentinelThenPath() throws { + #expect(try Parser.maskedPaths(["NONE", "/run/secrets"]) == ["/run/secrets"]) + } + + @Test + func testMaskedPathsParserPathThenResetSentinel() throws { + #expect(try Parser.maskedPaths(["/run/secrets", "NONE"]) == []) + } + + @Test + func testMaskedPathsParserResetSentinelCaseInsensitive() throws { + #expect(try Parser.maskedPaths(["none"]) == []) + #expect(try Parser.maskedPaths(["None"]) == []) + } + + @Test + func testMaskedPathsParserOrderedResets() throws { + #expect(try Parser.maskedPaths(["/a", "NONE", "/b", "/c"]) == ["/b", "/c"]) + } + + @Test + func testMaskedPathsParserStripsTrailingSlash() throws { + #expect(try Parser.maskedPaths(["NONE", "/run/secrets/"]) == ["/run/secrets"]) + #expect(try Parser.maskedPaths(["NONE", "/"]) == ["/"]) + } + + @Test + func testMaskedPathsParserTrimsWhitespace() throws { + #expect(try Parser.maskedPaths(["NONE", " /run/secrets "]) == ["/run/secrets"]) + } + + @Test + func testMaskedPathsParserDedupesRepeatedValues() throws { + #expect(try Parser.maskedPaths(["NONE", "/run/secrets", "/run/secrets/", "/run/secrets"]) == ["/run/secrets"]) + } + + @Test + func testMaskedPathsParserDedupesAgainstDefaults() throws { + let defaults = LinuxContainer.defaultMaskedPaths() + #expect(try Parser.maskedPaths([defaults[0]]) == defaults) + } + + @Test + func testMaskedPathsParserRelativePath() throws { + #expect { + _ = try Parser.maskedPaths(["proc/kcore"]) + } throws: { error in + "\(error)".contains("proc/kcore") && "\(error)".contains("masked-path") + } + } + + @Test + func testMaskedPathsParserEmptyValue() throws { + #expect { + _ = try Parser.maskedPaths([""]) + } throws: { _ in + true + } + } + + // MARK: - Readonly Paths Parser Tests + + @Test + func testReadonlyPathsParserEmpty() throws { + #expect(try Parser.readonlyPaths([]) == nil) + } + + @Test + func testReadonlyPathsParserAppendsToDefaults() throws { + let result = try Parser.readonlyPaths(["/etc/config"]) + #expect(result == LinuxContainer.defaultReadonlyPaths() + ["/etc/config"]) + } + + @Test + func testReadonlyPathsParserResetSentinelOnly() throws { + #expect(try Parser.readonlyPaths(["NONE"]) == []) + } + + @Test + func testReadonlyPathsParserResetSentinelThenPath() throws { + #expect(try Parser.readonlyPaths(["NONE", "/etc/config"]) == ["/etc/config"]) + } + + @Test + func testReadonlyPathsParserPathThenResetSentinel() throws { + #expect(try Parser.readonlyPaths(["/etc/config", "NONE"]) == []) + } + + @Test + func testReadonlyPathsParserResetSentinelCaseInsensitive() throws { + #expect(try Parser.readonlyPaths(["none"]) == []) + } + + @Test + func testReadonlyPathsParserOrderedResets() throws { + #expect(try Parser.readonlyPaths(["/a", "NONE", "/b", "/c"]) == ["/b", "/c"]) + } + + @Test + func testReadonlyPathsParserStripsTrailingSlash() throws { + #expect(try Parser.readonlyPaths(["NONE", "/etc/config/"]) == ["/etc/config"]) + } + + @Test + func testReadonlyPathsParserDedupesAgainstDefaults() throws { + let defaults = LinuxContainer.defaultReadonlyPaths() + #expect(try Parser.readonlyPaths([defaults[0]]) == defaults) + } + + @Test + func testReadonlyPathsParserRelativePath() throws { + #expect { + _ = try Parser.readonlyPaths(["proc/sys"]) + } throws: { error in + "\(error)".contains("proc/sys") && "\(error)".contains("read-only-path") + } + } + + @Test + func testReadonlyPathsParserDefaultsAreDistinctFromMaskedPaths() throws { + let masked = try Parser.maskedPaths(["/shared"]) + let readonly = try Parser.readonlyPaths(["/shared"]) + #expect(masked == LinuxContainer.defaultMaskedPaths() + ["/shared"]) + #expect(readonly == LinuxContainer.defaultReadonlyPaths() + ["/shared"]) + #expect(masked != readonly) + } + // MARK: - Parser.resources @Test func testResourcesCustomDefaults() throws { diff --git a/Tests/IntegrationTests/Run/TestCLIRunSecurityPaths.swift b/Tests/IntegrationTests/Run/TestCLIRunSecurityPaths.swift new file mode 100644 index 000000000..91ec5662b --- /dev/null +++ b/Tests/IntegrationTests/Run/TestCLIRunSecurityPaths.swift @@ -0,0 +1,169 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerTestSupport +import Containerization +import Foundation +import Testing + +@Suite +struct TestCLIRunSecurityPaths { + private let alpine = WarmupImage.alpine320 + + /// Mount points inside the container. A masked file is a bind mount of + /// /dev/null, a masked directory is a tmpfs, and a read-only path is a bind + /// mount of itself, so every applied path appears here. + private func mountPoints(_ f: ContainerFixture, _ c: String) throws -> Set { + let mounts = try f.doExec(c, cmd: ["cat", "/proc/mounts"]) + return Set( + mounts.split(separator: "\n").compactMap { line in + let fields = line.split(separator: " ") + return fields.count > 1 ? String(fields[1]) : nil + }) + } + + // Whether an individual default path is applied depends on the guest kernel. + // To make the tests independent of the kernel and its config, + // our assertions should only claim that a default set is entirely + // absent, or that at least some of it is present. We don't check for a particular path. + private var maskedDefaults: Set { Set(LinuxContainer.defaultMaskedPaths()) } + private var readonlyDefaults: Set { Set(LinuxContainer.defaultReadonlyPaths()) } + + private func trimmed(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Invalid paths + + @Test func testRelativePathsRejected() async throws { + try await ContainerFixture.with { f in + let masked = try f.run(["run", "--rm", "--masked-path", "proc/kcore", alpine.rawValue, "true"]) + #expect(masked.status != 0) + #expect(masked.error.contains("proc/kcore")) + + let readonly = try f.run(["run", "--rm", "--read-only-path", "proc/sys", alpine.rawValue, "true"]) + #expect(readonly.status != 0) + #expect(readonly.error.contains("proc/sys")) + } + } + + // MARK: - Runtime defaults + + @Test func testNoFlagsUsesRuntimeDefaults() async throws { + try await ContainerFixture.with { f in + let c = "\(f.testID)-c" + try await f.doLongRun(name: c, image: alpine.rawValue, autoRemove: false, waitUntilRunning: true) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + // Absent from the stored config, so the runtime applies its defaults. + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.maskedPaths == nil) + #expect(inspect.configuration.readonlyPaths == nil) + + // At least one read-only default is always applied (/proc/sys and + // friends exist on every kernel); the masked set is kernel-dependent, + // so masking behavior is asserted on a path the image guarantees in + // testCustomPathsAppendToDefaults instead. + let mounted = try mountPoints(f, c) + #expect(!mounted.isDisjoint(with: readonlyDefaults)) + } + } + + // MARK: - Paths added on top of the defaults + + @Test func testCustomPathsAppendToDefaults() async throws { + try await ContainerFixture.with { f in + let c = "\(f.testID)-c" + try await f.doLongRun( + name: c, image: alpine.rawValue, + args: ["--masked-path", "/etc/alpine-release", "--read-only-path", "/tmp"], + autoRemove: false, waitUntilRunning: true) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.maskedPaths == LinuxContainer.defaultMaskedPaths() + ["/etc/alpine-release"]) + #expect(inspect.configuration.readonlyPaths == LinuxContainer.defaultReadonlyPaths() + ["/tmp"]) + + // The custom masked file reads as empty and the custom read-only + // directory rejects writes. + #expect(trimmed(try f.doExec(c, cmd: ["sh", "-c", "wc -c < /etc/alpine-release"])) == "0") + let write = try f.run(["exec", c, "sh", "-c", "touch /tmp/nope && echo WROTE"]) + #expect(write.status != 0) + #expect(trimmed(write.output) != "WROTE") + + // The defaults are still applied alongside them. + let mounted = try mountPoints(f, c) + #expect(mounted.contains("/etc/alpine-release")) + #expect(mounted.contains("/tmp")) + #expect(!mounted.isDisjoint(with: readonlyDefaults)) + } + } + + // MARK: - NONE sentinel + + @Test func testMaskedPathNoneClearsOnlyMaskedDefaults() async throws { + try await ContainerFixture.with { f in + let c = "\(f.testID)-c" + try await f.doLongRun( + name: c, image: alpine.rawValue, + args: ["--masked-path", "NONE"], autoRemove: false, waitUntilRunning: true) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.maskedPaths == []) + #expect(inspect.configuration.readonlyPaths == nil) + + // An empty list reaches the runtime as "mask nothing", and leaves the + // read-only defaults alone. + let mounted = try mountPoints(f, c) + #expect(mounted.isDisjoint(with: maskedDefaults)) + #expect(!mounted.isDisjoint(with: readonlyDefaults)) + } + } + + @Test func testReadOnlyPathNoneClearsOnlyReadOnlyDefaults() async throws { + try await ContainerFixture.with { f in + let c = "\(f.testID)-c" + try await f.doLongRun( + name: c, image: alpine.rawValue, + args: ["--read-only-path", "NONE", "--masked-path", "/etc/alpine-release"], + autoRemove: false, waitUntilRunning: true) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.readonlyPaths == []) + #expect(inspect.configuration.maskedPaths == LinuxContainer.defaultMaskedPaths() + ["/etc/alpine-release"]) + + // Nothing is read-only, while masking still works — the sentinel + // applies only to the flag it was passed to. + let mounted = try mountPoints(f, c) + #expect(mounted.isDisjoint(with: readonlyDefaults)) + #expect(trimmed(try f.doExec(c, cmd: ["sh", "-c", "wc -c < /etc/alpine-release"])) == "0") + } + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 6f80ca995..78a1835e7 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -57,6 +57,7 @@ container run [] [ ...] * `--init-image `: Use a custom init image instead of the default. This allows customizing boot-time behavior before the OCI container starts, such as running VM-level daemons, configuring eBPF filters, or debugging the init process. * `-k, --kernel `: Set a custom kernel path * `-l, --label