diff --git a/Sources/Configuration/Documentation.docc/Proposals/SCO-0004.md b/Sources/Configuration/Documentation.docc/Proposals/SCO-0004.md new file mode 100644 index 0000000..e27e201 --- /dev/null +++ b/Sources/Configuration/Documentation.docc/Proposals/SCO-0004.md @@ -0,0 +1,121 @@ +# SCO-0004: SecretMarkingProvider + +Add a wrapper provider to mark configuration values as secrets based on key patterns. + +## Overview + +- Proposal: SCO-0004 +- Author(s): [Gautam Raju](https://github.com/gautamrajur) +- Status: **Awaiting Review** +- Issue: [apple/swift-configuration#131](https://github.com/apple/swift-configuration/issues/131) +- Implementation: + - [apple/swift-configuration#XX](https://github.com/apple/swift-configuration/pull/XX) + +### Introduction + +Add `SecretMarkingProvider`, a wrapper provider that marks configuration values as secrets based on key patterns, enabling post-hoc secret identification for providers that don't natively support it. + +### Motivation + +When integrating with external configuration sources, values may not be properly marked as secrets. This is common with: + +- Environment variables from external systems +- Third-party configuration providers +- Legacy configuration files without secret metadata + +Currently, `SecretsSpecifier` only works at provider initialization time. If you receive configuration from a provider you don't control, there's no way to mark specific values as secrets without implementing a custom wrapper. + +For example, an `EnvironmentVariablesProvider` initialized without a `secretsSpecifier` will expose all values as non-secrets, including sensitive data like `DATABASE_PASSWORD` or `API_TOKEN`. + +### Proposed solution + +Add `SecretMarkingProvider`, a generic wrapper that: +1. Delegates all lookups to the upstream provider +2. Marks values as secrets when the key matches a user-provided predicate +3. Preserves existing secret status (never removes the `isSecret` flag) + +```swift +let envProvider = EnvironmentVariablesProvider() + +let secretMarkedProvider = SecretMarkingProvider(upstream: envProvider) { key in + key.description.lowercased().contains("password") || + key.description.lowercased().contains("secret") +} + +let config = ConfigReader(provider: secretMarkedProvider) +let dbPassword = config.string(forKey: "database.password") // marked as secret +``` + +Convenience methods on `ConfigProvider`: + +```swift +// Predicate-based +let provider = envProvider.markSecrets { $0.description.contains("password") } + +// Set-based +let provider = envProvider.markSecretsForKeys(["database.password", "api.key"]) +``` + +### Detailed design + +#### SecretMarkingProvider + +A generic struct wrapping any `ConfigProvider`: + +```swift +public struct SecretMarkingProvider: Sendable { + private let isSecretKey: @Sendable (AbsoluteConfigKey) -> Bool + private let upstream: Upstream + + public init( + upstream: Upstream, + isSecretKey: @Sendable @escaping (_ key: AbsoluteConfigKey) -> Bool + ) +} +``` + +Implements all `ConfigProvider` methods by delegating to upstream and marking results: +- `value(forKey:type:)` +- `fetchValue(forKey:type:)` +- `watchValue(forKey:type:updatesHandler:)` +- `snapshot()` +- `watchSnapshot(updatesHandler:)` + +#### SecretMarkedSnapshot + +A private snapshot wrapper that applies the same secret-marking logic. + +#### Convenience operators + +Extensions on `ConfigProvider`: + +```swift +extension ConfigProvider { + func markSecrets( + where isSecretKey: @Sendable @escaping (AbsoluteConfigKey) -> Bool + ) -> SecretMarkingProvider + + func markSecretsForKeys(_ keys: Set) -> SecretMarkingProvider +} +``` + +### API stability + +This change is purely additive: +- New public type: `SecretMarkingProvider` +- New methods on `ConfigProvider`: `markSecrets(where:)`, `markSecretsForKeys(_:)` +- No changes to existing APIs + +### Future directions + +- Could integrate with `SecretsSpecifier` to provide a unified API for secret detection +- Could add logging/metrics for when secrets are marked + +### Alternatives considered + +1. **Extend SecretsSpecifier to work post-hoc** - Would require significant changes to the provider protocol and existing implementations. + +2. **Add secret marking to ConfigReader** - Would only work at the reader level, not propagated through snapshots or watch sequences. + +3. **Document the workaround** - Users could implement their own wrapper, but this is boilerplate that the library should provide. + diff --git a/Sources/Configuration/Providers/Wrappers/ConfigProvider+Operators.swift b/Sources/Configuration/Providers/Wrappers/ConfigProvider+Operators.swift index 73c387e..aaaa8f0 100644 --- a/Sources/Configuration/Providers/Wrappers/ConfigProvider+Operators.swift +++ b/Sources/Configuration/Providers/Wrappers/ConfigProvider+Operators.swift @@ -33,4 +33,22 @@ extension ConfigProvider { ) -> KeyMappingProvider { KeyMappingProvider(upstream: self, keyMapper: transform) } + + /// Creates a provider that marks values as secrets based on the given predicate. + /// + /// - Parameter isSecretKey: A closure that returns `true` for keys whose values should be secrets. + /// - Returns: A provider that marks matching values as secrets. + public func markSecrets( + where isSecretKey: @Sendable @escaping (_ key: AbsoluteConfigKey) -> Bool + ) -> SecretMarkingProvider { + SecretMarkingProvider(upstream: self, isSecretKey: isSecretKey) + } + + /// Creates a provider that marks values as secrets for the specified keys. + /// + /// - Parameter keys: Keys whose values should be marked as secrets. + /// - Returns: A provider that marks the specified keys' values as secrets. + public func markSecretsForKeys(_ keys: Set) -> SecretMarkingProvider { + SecretMarkingProvider(upstream: self) { keys.contains($0) } + } } diff --git a/Sources/Configuration/Providers/Wrappers/SecretMarkingProvider.swift b/Sources/Configuration/Providers/Wrappers/SecretMarkingProvider.swift new file mode 100644 index 0000000..8c4be12 --- /dev/null +++ b/Sources/Configuration/Providers/Wrappers/SecretMarkingProvider.swift @@ -0,0 +1,197 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// A configuration provider that marks values as secrets based on key patterns. +/// +/// Use `SecretMarkingProvider` to mark configuration values as secrets when the upstream +/// provider doesn't identify sensitive data. This is particularly useful when integrating +/// with external configuration sources or when you want to apply consistent secret handling +/// across providers that use different conventions. +/// +/// ### Common use cases +/// +/// Use `SecretMarkingProvider` for: +/// - Marking environment variables containing passwords or API keys as secrets. +/// - Adding secret protection to third-party configuration providers. +/// +/// ## Example +/// +/// Use `SecretMarkingProvider` when you want to mark secrets for specific providers: +/// +/// ```swift +/// let envProvider = EnvironmentVariablesProvider() +/// +/// let secretMarkedProvider = SecretMarkingProvider(upstream: envProvider) { key in +/// key.description.lowercased().contains("password") || +/// key.description.lowercased().contains("secret") +/// } +/// +/// let config = ConfigReader(provider: secretMarkedProvider) +/// let dbPassword = config.string(forKey: "database.password") // marked as secret +/// ``` +/// +/// ## Convenience method +/// +/// You can also use the ``ConfigProvider/markSecrets(where:)`` convenience method: +/// +/// ```swift +/// let provider = EnvironmentVariablesProvider() +/// .markSecrets { $0.description.contains("password") } +/// ``` +@available(Configuration 1.0, *) +public struct SecretMarkingProvider: Sendable { + /// The predicate to check if a key's value should be marked as secret. + private let isSecretKey: @Sendable (AbsoluteConfigKey) -> Bool + + /// The upstream provider. + private let upstream: Upstream + + /// Creates a new provider that marks values as secrets based on a predicate. + /// + /// - Parameters: + /// - upstream: The upstream provider to delegate to. + /// - isSecretKey: A closure that determines whether values for a given key should be marked as secrets. + public init( + upstream: Upstream, + isSecretKey: @Sendable @escaping (_ key: AbsoluteConfigKey) -> Bool + ) { + self.isSecretKey = isSecretKey + self.upstream = upstream + } +} + +@available(Configuration 1.0, *) +extension SecretMarkingProvider { + private func markSecretIfNeeded(_ value: ConfigValue?, forKey key: AbsoluteConfigKey) -> ConfigValue? { + guard var value else { return nil } + if isSecretKey(key) { + value = ConfigValue(value.content, isSecret: true) + } + return value + } + + private func markSecretIfNeeded(_ result: LookupResult, forKey key: AbsoluteConfigKey) -> LookupResult { + LookupResult( + encodedKey: result.encodedKey, + value: markSecretIfNeeded(result.value, forKey: key) + ) + } +} + +@available(Configuration 1.0, *) +extension SecretMarkingProvider: ConfigProvider { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var providerName: String { + "SecretMarkingProvider[upstream: \(upstream.providerName)]" + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { + let result = try upstream.value(forKey: key, type: type) + return markSecretIfNeeded(result, forKey: key) + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func fetchValue(forKey key: AbsoluteConfigKey, type: ConfigType) async throws -> LookupResult { + let result = try await upstream.fetchValue(forKey: key, type: type) + return markSecretIfNeeded(result, forKey: key) + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func watchValue( + forKey key: AbsoluteConfigKey, + type: ConfigType, + updatesHandler: ( + _ updates: ConfigUpdatesAsyncSequence, Never> + ) async throws -> Return + ) async throws -> Return { + try await upstream.watchValue(forKey: key, type: type) { sequence in + try await updatesHandler( + ConfigUpdatesAsyncSequence( + sequence + .map { result in + result.map { lookupResult in + self.markSecretIfNeeded(lookupResult, forKey: key) + } + } + ) + ) + } + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func snapshot() -> any ConfigSnapshot { + SecretMarkedSnapshot(isSecretKey: self.isSecretKey, upstream: self.upstream.snapshot()) + } + + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public func watchSnapshot( + updatesHandler: (_ updates: ConfigUpdatesAsyncSequence) async throws -> Return + ) async throws -> Return { + try await upstream.watchSnapshot { sequence in + try await updatesHandler( + ConfigUpdatesAsyncSequence( + sequence + .map { snapshot in + SecretMarkedSnapshot(isSecretKey: self.isSecretKey, upstream: snapshot) + } + ) + ) + } + } +} + +/// A snapshot that marks values as secrets based on key patterns. +@available(Configuration 1.0, *) +private struct SecretMarkedSnapshot: ConfigSnapshot { + + /// The predicate to check if a key's value should be marked as secret. + let isSecretKey: @Sendable (AbsoluteConfigKey) -> Bool + + /// The upstream snapshot to delegate to. + var upstream: any ConfigSnapshot + + var providerName: String { + "SecretMarkingProvider[upstream: \(self.upstream.providerName)]" + } + + func value(forKey key: AbsoluteConfigKey, type: ConfigType) throws -> LookupResult { + let result = try upstream.value(forKey: key, type: type) + guard var value = result.value else { + return result + } + if isSecretKey(key) { + value = ConfigValue(value.content, isSecret: true) + } + return LookupResult(encodedKey: result.encodedKey, value: value) + } +} + +@available(Configuration 1.0, *) +extension SecretMarkingProvider: CustomStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var description: String { + "SecretMarkingProvider[upstream: \(self.upstream)]" + } +} + +@available(Configuration 1.0, *) +extension SecretMarkingProvider: CustomDebugStringConvertible { + // swift-format-ignore: AllPublicDeclarationsHaveDocumentation + public var debugDescription: String { + let upstreamDebug = String(reflecting: self.upstream) + return "SecretMarkingProvider[upstream: \(upstreamDebug)]" + } +} + diff --git a/Tests/ConfigurationTests/SecretMarkingProviderTests.swift b/Tests/ConfigurationTests/SecretMarkingProviderTests.swift new file mode 100644 index 0000000..eb01c8c --- /dev/null +++ b/Tests/ConfigurationTests/SecretMarkingProviderTests.swift @@ -0,0 +1,178 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftConfiguration open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftConfiguration project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftConfiguration project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Testing +import ConfigurationTestingInternal +@testable import Configuration +import Foundation +import ConfigurationTesting + +struct SecretMarkingProviderTests { + + @available(Configuration 1.0, *) + @Test func marksMatchingKeysAsSecret() throws { + let upstream = InMemoryProvider(values: [ + "database.password": "secret-pass", + "database.host": "localhost", + ]) + let provider = SecretMarkingProvider(upstream: upstream) { key in + key.description.contains("password") + } + + let passwordResult = try provider.value(forKey: ["database", "password"], type: .string) + #expect(passwordResult.value?.isSecret == true) + + let hostResult = try provider.value(forKey: ["database", "host"], type: .string) + #expect(hostResult.value?.isSecret == false) + } + + @available(Configuration 1.0, *) + @Test func preservesExistingSecrets() throws { + let upstream = InMemoryProvider(values: [ + "api.key": ConfigValue(.string("already-secret"), isSecret: true), + "other.key": ConfigValue(.string("not-secret"), isSecret: false), + ]) + // Predicate doesn't match "api.key" but it should stay secret + let provider = SecretMarkingProvider(upstream: upstream) { _ in false } + + let apiKeyResult = try provider.value(forKey: ["api", "key"], type: .string) + #expect(apiKeyResult.value?.isSecret == true) + + let otherResult = try provider.value(forKey: ["other", "key"], type: .string) + #expect(otherResult.value?.isSecret == false) + } + + @available(Configuration 1.0, *) + @Test func fetchValueMarksSecret() async throws { + let upstream = InMemoryProvider(values: ["api.secret": "token"]) + let provider = upstream.markSecrets { $0.description.contains("secret") } + + let result = try await provider.fetchValue(forKey: ["api", "secret"], type: .string) + #expect(result.value?.isSecret == true) + } + + @available(Configuration 1.0, *) + @Test func watchValueMarksSecret() async throws { + let upstream = InMemoryProvider(values: ["jwt.token": "eyJ..."]) + let provider = upstream.markSecrets { $0.description.contains("token") } + + try await provider.watchValue(forKey: ["jwt", "token"], type: .string) { sequence in + for try await result in sequence { + let lookupResult = try result.get() + #expect(lookupResult.value?.isSecret == true) + break + } + } + } + + @available(Configuration 1.0, *) + @Test func snapshotMarksSecret() throws { + let upstream = InMemoryProvider(values: ["db.password": "pass", "db.name": "mydb"]) + let provider = upstream.markSecrets { $0.description.contains("password") } + let snapshot = provider.snapshot() + + #expect(try snapshot.value(forKey: ["db", "password"], type: .string).value?.isSecret == true) + #expect(try snapshot.value(forKey: ["db", "name"], type: .string).value?.isSecret == false) + } + + @available(Configuration 1.0, *) + @Test func watchSnapshotMarksSecret() async throws { + let upstream = InMemoryProvider(values: ["auth.secret": "shh"]) + let provider = upstream.markSecrets { $0.description.contains("secret") } + + try await provider.watchSnapshot { sequence in + for try await snapshot in sequence { + let result = try snapshot.value(forKey: ["auth", "secret"], type: .string) + #expect(result.value?.isSecret == true) + break + } + } + } + + @available(Configuration 1.0, *) + @Test func providerName() { + let upstream = InMemoryProvider(name: "test", values: [:]) + let provider = SecretMarkingProvider(upstream: upstream) { _ in false } + #expect(provider.providerName == "SecretMarkingProvider[upstream: InMemoryProvider[test]]") + } + + @available(Configuration 1.0, *) + @Test func description() { + let upstream = InMemoryProvider(name: "test", values: [:]) + let provider = SecretMarkingProvider(upstream: upstream) { _ in false } + #expect(provider.description == "SecretMarkingProvider[upstream: InMemoryProvider[test, 0 values]]") + } + + @available(Configuration 1.0, *) + @Test func nilValueHandling() throws { + let upstream = InMemoryProvider(values: [:]) + let provider = upstream.markSecrets { _ in true } + let result = try provider.value(forKey: ["nonexistent"], type: .string) + #expect(result.value == nil) + } + + @available(Configuration 1.0, *) + @Test func markSecretsForKeysOperator() throws { + let upstream = InMemoryProvider(values: [ + "database.password": "pass", + "database.host": "localhost", + ]) + let provider = upstream.markSecretsForKeys([["database", "password"]]) + + #expect(try provider.value(forKey: ["database", "password"], type: .string).value?.isSecret == true) + #expect(try provider.value(forKey: ["database", "host"], type: .string).value?.isSecret == false) + } + + @available(Configuration 1.0, *) + @Test func chainingWithKeyMapping() throws { + let upstream = InMemoryProvider(values: ["app.database.password": "secret"]) + let provider = upstream + .prefixKeys(with: "myapp") + .markSecrets { $0.description.contains("password") } + + let result = try provider.value(forKey: ["database", "password"], type: .string) + #expect(result.value?.isSecret == true) + } + + @available(Configuration 1.0, *) + @Test func compat() async throws { + let upstream = InMemoryProvider( + name: "test", + values: [ + "string": "Hello", + "other.string": "Other Hello", + "int": 42, + "other.int": 24, + "double": 3.14, + "other.double": 2.72, + "bool": true, + "other.bool": false, + "bytes": ConfigValue(.magic, isSecret: false), + "other.bytes": ConfigValue(.magic2, isSecret: false), + "stringy.array": ConfigValue(["Hello", "World"], isSecret: false), + "other.stringy.array": ConfigValue(["Hello", "Swift"], isSecret: false), + "inty.array": ConfigValue([42, 24], isSecret: false), + "other.inty.array": ConfigValue([16, 32], isSecret: false), + "doubly.array": ConfigValue([3.14, 2.72], isSecret: false), + "other.doubly.array": ConfigValue([0.9, 1.8], isSecret: false), + "booly.array": ConfigValue([true, false], isSecret: false), + "other.booly.array": ConfigValue([false, true, true], isSecret: false), + "byteChunky.array": ConfigValue([.magic, .magic2], isSecret: false), + "other.byteChunky.array": ConfigValue([.magic, .magic2, .magic], isSecret: false), + ] + ) + let provider = SecretMarkingProvider(upstream: upstream) { _ in false } + try await ProviderCompatTest(provider: provider).runTest() + } +}