Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions Sources/ContainerizationOCI/Spec+Redaction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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.
//===----------------------------------------------------------------------===//

extension Spec {
/// Returns a copy of the spec that is safe to include in log output:
/// the environment variable values of the container process and of all
/// lifecycle hooks are replaced with `<redacted>`, keeping only the
/// variable names. Environment variables routinely carry secrets, and
/// logs must not expose them.
public func redactingEnvironmentValues() -> Spec {
var copy = self
copy.process = copy.process?.redactingEnvironmentValues()
copy.hooks = copy.hooks?.redactingEnvironmentValues()
return copy
}
}

extension Process {
/// Returns a copy of the process whose environment variable values are
/// replaced with `<redacted>`, keeping only the variable names. Use this
/// when rendering a process into log output so secrets injected through
/// the environment are not exposed.
public func redactingEnvironmentValues() -> Process {
var copy = self
copy.env = redactedEnvironment(copy.env)
return copy
}
}

extension Hook {
/// Returns a copy of the hook whose environment variable values are
/// replaced with `<redacted>`, keeping only the variable names.
public func redactingEnvironmentValues() -> Hook {
var copy = self
copy.env = redactedEnvironment(copy.env)
return copy
}
}

extension Hooks {
/// Returns a copy of the hooks whose environment variable values are
/// replaced with `<redacted>`, keeping only the variable names.
public func redactingEnvironmentValues() -> Hooks {
var copy = self
copy.prestart = copy.prestart.map { $0.redactingEnvironmentValues() }
copy.createRuntime = copy.createRuntime.map { $0.redactingEnvironmentValues() }
copy.createContainer = copy.createContainer.map { $0.redactingEnvironmentValues() }
copy.startContainer = copy.startContainer.map { $0.redactingEnvironmentValues() }
copy.poststart = copy.poststart.map { $0.redactingEnvironmentValues() }
copy.poststop = copy.poststop.map { $0.redactingEnvironmentValues() }
return copy
}
}

/// Replaces the value of every `NAME=value` entry with `<redacted>`. Entries
/// without an `=` are kept as-is: they name variables to inherit and carry
/// no value of their own.
private func redactedEnvironment(_ env: [String]) -> [String] {
env.map { entry in
guard let separator = entry.firstIndex(of: "=") else {
return entry
}
return entry[..<separator] + "=<redacted>"
}
}
113 changes: 113 additions & 0 deletions Tests/ContainerizationOCITests/SpecRedactionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization 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 Foundation
import Testing

@testable import ContainerizationOCI

struct SpecRedactionTests {
@Test func processEnvironmentValuesAreRedacted() {
let process = ContainerizationOCI.Process(
args: ["python3", "-m", "http.server"],
cwd: "/app",
env: [
"PATH=/usr/local/bin:/usr/bin",
"MY_SUPER_SECRET_PASSWORD=guest",
"EMPTY=",
"TOKEN=abc=def",
"INHERITED_NO_VALUE",
]
)

let redacted = process.redactingEnvironmentValues()

#expect(
redacted.env == [
"PATH=<redacted>",
"MY_SUPER_SECRET_PASSWORD=<redacted>",
"EMPTY=<redacted>",
"TOKEN=<redacted>",
"INHERITED_NO_VALUE",
])
// Everything except env is untouched.
#expect(redacted.args == process.args)
#expect(redacted.cwd == process.cwd)
// The original is not mutated.
#expect(process.env.contains("MY_SUPER_SECRET_PASSWORD=guest"))
}

@Test func redactedProcessRendersWithoutSecretValues() {
// Mirrors the report in issue #518: interpolating the process into a
// log message must not expose environment variable values.
let process = ContainerizationOCI.Process(
args: ["echo", "hello"],
env: ["MY_OTHER_SECRET_PASSWORD=abc123"]
)

let logged = "\(process.redactingEnvironmentValues())"

#expect(!logged.contains("abc123"))
#expect(logged.contains("MY_OTHER_SECRET_PASSWORD"))
}

@Test func specRedactsProcessAndHookEnvironments() {
let hook = Hook(
path: "/usr/local/bin/hook",
args: ["hook"],
env: ["HOOK_SECRET=hunter2"],
timeout: nil
)
let spec = Spec(
hooks: Hooks(
prestart: [hook],
createRuntime: [hook],
createContainer: [hook],
startContainer: [hook],
poststart: [hook],
poststop: [hook]
),
process: ContainerizationOCI.Process(env: ["MY_SUPER_SECRET_PASSWORD=guest"]),
hostname: "web",
mounts: [Mount(type: "proc", source: "proc", destination: "/proc")]
)

let redacted = spec.redactingEnvironmentValues()

let logged = "\(redacted)"
#expect(!logged.contains("guest"))
#expect(!logged.contains("hunter2"))
#expect(logged.contains("MY_SUPER_SECRET_PASSWORD"))
#expect(logged.contains("HOOK_SECRET"))

// Everything except environments is untouched.
#expect(redacted.hostname == spec.hostname)
#expect(redacted.mounts.count == spec.mounts.count)
#expect(redacted.process?.env == ["MY_SUPER_SECRET_PASSWORD=<redacted>"])
#expect(redacted.hooks?.prestart.first?.env == ["HOOK_SECRET=<redacted>"])
#expect(redacted.hooks?.poststop.first?.env == ["HOOK_SECRET=<redacted>"])
// The original is not mutated.
#expect(spec.process?.env == ["MY_SUPER_SECRET_PASSWORD=guest"])
}

@Test func specWithoutProcessOrHooksIsUnchanged() {
let spec = Spec(version: "1.2.0")
let redacted = spec.redactingEnvironmentValues()
#expect(redacted.process == nil)
#expect(redacted.hooks == nil)
#expect(redacted.version == spec.version)
}
}
4 changes: 2 additions & 2 deletions vminitd/Sources/VminitdCore/ManagedContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public actor ManagedContainer {
path: Self.craftBundlePath(id: id),
spec: spec
)
log.debug("created bundle with spec \(spec)")
log.debug("created bundle with spec \(spec.redactingEnvironmentValues())")

let cgManager = Cgroup2Manager(
group: URL(filePath: cgroupsPath),
Expand Down Expand Up @@ -164,7 +164,7 @@ extension ManagedContainer {
stdio: HostStdio,
process: ContainerizationOCI.Process
) throws {
log.debug("creating exec process with \(process)")
log.debug("creating exec process with \(process.redactingEnvironmentValues())")

// Write the process config to the bundle, and pass this on
// over to ManagedProcess to deal with.
Expand Down