Add --read-only-path and --masked-path option to container run / create (#2069)

This commit is contained in:
Aditya Ramani
2026-08-04 19:04:50 -07:00
committed by GitHub
parent cee0ba80cd
commit ddaf2ca5ac
7 changed files with 438 additions and 0 deletions
@@ -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
@@ -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? {
@@ -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 {
@@ -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 {
@@ -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<String> {
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<String> { Set(LinuxContainer.defaultMaskedPaths()) }
private var readonlyDefaults: Set<String> { 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")
}
}
}
+4
View File
@@ -57,6 +57,7 @@ container run [<options>] <image> [<arguments> ...]
* `--init-image <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 <path>`: Set a custom kernel path
* `-l, --label <label>`: Add a key=value label to the container
* `--masked-path <path>`: **Experimental.** Hide a path inside the container, in addition to the runtime defaults (or `NONE` to clear prior values and the defaults)
* `--mount <mount>`: Add a mount to the container (format: type=<>,source=<>,target=<>,readonly)
* `--name <name>`: Use the specified name as the container ID
* `--network <network>`: Attach the container to a network (format: `<name>[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE]`)
@@ -66,6 +67,7 @@ container run [<options>] <image> [<arguments> ...]
* `--platform <platform>`: Platform for the image if it's multi-platform. This takes precedence over --os and --arch
* `--publish-socket <spec>`: Publish a socket from container to host (format: host_path:container_path)
* `--read-only`: Mount the container's root filesystem as read-only
* `--read-only-path <path>`: **Experimental.** Mark a path inside the container read-only, in addition to the runtime defaults (or `NONE` to clear prior values and the defaults)
* `--rm, --remove`: Remove the container after it stops
* `--rosetta`: Enable Rosetta in the container
* `--runtime`: Set the runtime handler for the container (default: container-runtime-linux)
@@ -231,6 +233,7 @@ container create [<options>] <image> [<arguments> ...]
* `--init-image <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 <path>`: Set a custom kernel path
* `-l, --label <label>`: Add a key=value label to the container
* `--masked-path <path>`: **Experimental.** Hide a path inside the container, in addition to the runtime defaults (or `NONE` to clear prior values and the defaults)
* `--mount <mount>`: Add a mount to the container (format: type=<>,source=<>,target=<>,readonly)
* `--name <name>`: Use the specified name as the container ID
* `--network <network>`: Attach the container to a network (format: `<name>[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE]`)
@@ -240,6 +243,7 @@ container create [<options>] <image> [<arguments> ...]
* `--platform <platform>`: Platform for the image if it's multi-platform. This takes precedence over --os and --arch
* `--publish-socket <spec>`: Publish a socket from container to host (format: host_path:container_path)
* `--read-only`: Mount the container's root filesystem as read-only
* `--read-only-path <path>`: **Experimental.** Mark a path inside the container read-only, in addition to the runtime defaults (or `NONE` to clear prior values and the defaults)
* `--rm, --remove`: Remove the container after it stops
* `--rosetta`: Enable Rosetta in the container
* `--runtime`: Set the runtime handler for the container (default: container-runtime-linux)
+38
View File
@@ -512,6 +512,44 @@ chown: /tmp: Operation not permitted
```
## Mask and protect paths inside a container
> [!NOTE]
> `--masked-path` and `--read-only-path` are experimental. The behavior described here are subject to change in a future release.
By default, containers hide a set of sensitive paths from the workload, and mark another set read-only, matching the OCI runtime spec defaults that other production runtimes apply.
Masked by default (files are replaced with `/dev/null`, directories with an empty read-only tmpfs):
`/proc/asound`, `/proc/acpi`, `/proc/kcore`, `/proc/keys`, `/proc/latency_stats`, `/proc/timer_list`, `/proc/timer_stats`, `/proc/sched_debug`, `/proc/scsi`, `/sys/firmware`, `/sys/devices/virtual/powercap`
Read-only by default:
`/proc/bus`, `/proc/fs`, `/proc/irq`, `/proc/sys`, `/proc/sysrq-trigger`
You can extend either set using `--masked-path` and `--read-only-path` with `container run` or `container create`. Both flags can be repeated, take absolute paths, and add to the defaults rather than replacing them:
```console
% container run --masked-path /etc/alpine-release alpine cat /etc/alpine-release
% container run --read-only-path /tmp alpine touch /tmp/file
touch: /tmp/file: Read-only file system
```
To opt out of the defaults entirely, pass the `NONE` sentinel. It clears every path accumulated so far for that flag, including the defaults:
```bash
container run --masked-path NONE alpine ls /sys/firmware
```
Because values are processed in order, `NONE` can be followed by a custom set that replaces the defaults:
```bash
container run --masked-path NONE --masked-path /run/secrets alpine sh
```
The two flags are independent, so clearing the masked paths leaves the read-only defaults in place. The paths that a container was created with are visible in `container inspect` under `configuration.maskedPaths` and `configuration.readonlyPaths`; when neither flag is used, both are absent and the runtime defaults apply.
## Expose virtualization capabilities to a container
> [!NOTE]