mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
Add --read-only-path and --masked-path option to container run / create (#2069)
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user