Skip to content

Commit 16e9075

Browse files
committed
Sandbox blocks output to default plugin output directory when it's under <pkgdir>/.build
The sandbox rules introduced in #3996 made the entire package directory read-only, but that isn't appropriate when `.build` is inside the package directory. This change turns the stateless Sandbox enum into a SandboxProfile struct that can be configured and passed around well before being applied, and it makes the configuration more flexible in several regards: - there is a list of path rules allowing a mixed order of `allow` and `deny` rules - the choice of allowing writing to temporary directories is independent of anything other choice - the defaults are built into the initializer rather than a separate `strictness` parameter — this means that they can be queried once the SandboxProfile has been created and are expressed in terms of the choices available to all sandbox profiles. Having the sandbox profile be a struct that generates the platform specifics as needed also provides a place with which to associate any cached/compiled representation for profiles that are largely static. As cleanup, this commit also removes the pre-SwiftPM 5.3 specialities which were specific to running the package manifest in an interpreter rather than compiling and executing it. This functionality is no longer relevant since it isn't possible to run any manifest in the interpreter. In this commit, the call sites have been adjusted so that they use the modified SandboxProfile API, but still construct the profiles on-the-fly as before. A future change could make them instead be configured at an early point and then applied later when the sandboxed process is actually launched. Another future change could add the sandbox profile to Process as a property so that there is no API assumption that applying a sandbox necessarily involves just modifying the command line. rdar://87417780
1 parent f9f74f1 commit 16e9075

File tree

8 files changed

+200
-38
lines changed

8 files changed

+200
-38
lines changed

Sources/Basics/CMakeLists.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This source file is part of the Swift open source project
22
#
3-
# Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
3+
# Copyright (c) 2014 - 2022 Apple Inc. and the Swift project authors
44
# Licensed under Apache License v2.0 with Runtime Library Exception
55
#
66
# See http://swift.org/LICENSE.txt for license information
@@ -24,7 +24,7 @@ add_library(Basics
2424
JSONDecoder+Extensions.swift
2525
Netrc.swift
2626
Observability.swift
27-
Sandbox.swift
27+
SandboxProfile.swift
2828
String+Extensions.swift
2929
Triple+Extensions.swift
3030
SwiftVersion.swift

Sources/Basics/SandboxProfile.swift

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 - 2022 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
import TSCBasic
13+
import TSCUtility
14+
15+
/// A sandbox profile representing the desired restrictions. The implementation can vary between platforms.
16+
public struct SandboxProfile {
17+
/// Whether to allow outbound and inbound network access.
18+
public var allowNetwork: Bool
19+
20+
/// Whether to allow writing to system-defined temporary directories. Overridden by any rules in `pathRules`.
21+
public var allowWritingToTemporaryDirectories: Bool
22+
23+
/// An ordered list of path rules, where the last rule to cover a particular path "wins". These will be resolved
24+
/// to absolute paths at the time the profile is applied. They are applied after any of the implicit directories
25+
/// referenced by other sandbox profile settings.
26+
public var pathRules: [PathWritabilityRule]
27+
28+
/// Represents a readonly/writable rule for a path and everything under it.
29+
public enum PathWritabilityRule: Equatable {
30+
case readonly(AbsolutePath)
31+
case writable(AbsolutePath)
32+
}
33+
34+
/// Configures a SandboxProfile for blocking network access and writing to the file system except to specifically
35+
/// permitted locations.
36+
public init(
37+
allowNetwork: Bool = false,
38+
allowWritingToTemporaryDirectories: Bool = false,
39+
pathRules: [PathWritabilityRule] = []
40+
) {
41+
self.allowNetwork = allowNetwork
42+
self.allowWritingToTemporaryDirectories = allowWritingToTemporaryDirectories
43+
self.pathRules = pathRules
44+
}
45+
}
46+
47+
extension SandboxProfile {
48+
/// Applies the sandbox profile to the given command line (if the platform supports it), and returns the modified
49+
/// command line. On platforms that don't support sandboxing, the unmodified command line is returned.
50+
public func apply(to command: [String]) -> [String] {
51+
#if os(macOS)
52+
return ["/usr/bin/sandbox-exec", "-p", self.generateMacOSSandboxProfileString()] + command
53+
#else
54+
// rdar://40235432, rdar://75636874 tracks implementing sandboxes for other platforms.
55+
return command
56+
#endif
57+
}
58+
}
59+
60+
// MARK: - macOS
61+
62+
#if os(macOS)
63+
fileprivate extension SandboxProfile {
64+
/// Private function that generates a Darwin sandbox profile suitable for passing to `sandbox-exec(1)`.
65+
func generateMacOSSandboxProfileString() -> String {
66+
var contents = "(version 1)\n"
67+
68+
// Deny everything by default.
69+
contents += "(deny default)\n"
70+
71+
// Import the system sandbox profile.
72+
contents += "(import \"system.sb\")\n"
73+
74+
// Allow reading any file that isn't protected by TCC or permissions (ideally we'd only allow a specific set
75+
// of readable locations, and can maybe tighten this in the future).
76+
contents += "(allow file-read*)\n"
77+
78+
// Allow operations on subprocesses.
79+
contents += "(allow process*)\n"
80+
81+
// Optionally allow network access (inbound and outbound).
82+
if allowNetwork {
83+
contents += "(system-network)\n"
84+
contents += "(allow network*)\n"
85+
}
86+
87+
// Optionally allow writing to the platform-specific temporary directories.
88+
if allowWritingToTemporaryDirectories {
89+
contents += "(allow file-write*\n"
90+
for path in temporaryDirectories {
91+
contents += " (subpath \(resolveSymlinksAndQuotePath(path)))\n"
92+
}
93+
contents += ")\n"
94+
}
95+
96+
// Apply customized rules for specific file system locations. Everything is readonly by default, so we just
97+
// either allow or deny writing, in order. Later rules have precedence over earlier rules.
98+
for rule in pathRules {
99+
switch rule {
100+
case .readonly(let path):
101+
contents += "(deny file-write* (subpath \(resolveSymlinksAndQuotePath(path))))\n"
102+
case .writable(let path):
103+
contents += "(allow file-write* (subpath \(resolveSymlinksAndQuotePath(path))))\n"
104+
}
105+
}
106+
107+
return contents
108+
}
109+
110+
/// Private helper function that returns the temporary directories on macOS.
111+
var temporaryDirectories: [AbsolutePath] {
112+
return [AbsolutePath("/tmp"), AbsolutePath(NSTemporaryDirectory())]
113+
}
114+
115+
/// Private helper function that resolves an AbsolutePath and returns it as a string quoted for use as a subpath
116+
/// in a .sb sandbox profile.
117+
func resolveSymlinksAndQuotePath(_ path: AbsolutePath) -> String {
118+
return "\"" + resolveSymlinks(path).pathString
119+
.replacingOccurrences(of: "\\", with: "\\\\")
120+
.replacingOccurrences(of: "\"", with: "\\\"")
121+
+ "\""
122+
}
123+
}
124+
#endif

Sources/Build/BuildOperation.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,8 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS
452452
// TODO: We need to also use any working directory, but that support isn't yet available on all platforms at a lower level.
453453
var commandLine = [command.configuration.executable.pathString] + command.configuration.arguments
454454
if !self.disableSandboxForPluginCommands {
455-
commandLine = Sandbox.apply(command: commandLine, strictness: .writableTemporaryDirectory, writableDirectories: [pluginResult.pluginOutputDirectory])
455+
let sandboxProfile = SandboxProfile(allowWritingToTemporaryDirectories: true, pathRules: [.writable(pluginResult.pluginOutputDirectory)])
456+
commandLine = sandboxProfile.apply(to: commandLine)
456457
}
457458
let processResult = try Process.popen(arguments: commandLine, environment: command.configuration.environment)
458459
let output = try processResult.utf8Output() + processResult.utf8stderrOutput()

Sources/Build/LLBuildManifestBuilder.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,8 @@ extension LLBuildManifestBuilder {
620620
let displayName = command.configuration.displayName ?? execPath.basename
621621
var commandLine = [execPath.pathString] + command.configuration.arguments
622622
if !self.disableSandboxForPluginCommands {
623-
commandLine = Sandbox.apply(command: commandLine, strictness: .writableTemporaryDirectory, writableDirectories: [result.pluginOutputDirectory])
623+
let sandboxProfile = SandboxProfile(allowWritingToTemporaryDirectories: true, pathRules: [.writable(result.pluginOutputDirectory)])
624+
commandLine = sandboxProfile.apply(to: commandLine)
624625
}
625626
manifest.addShellCmd(
626627
name: displayName + "-" + ByteString(encodingAsUTF8: uniquedName).sha256Checksum,

Sources/PackageLoading/ManifestLoader.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -828,8 +828,8 @@ public final class ManifestLoader: ManifestLoaderProtocol {
828828
// We only allow the permissions which are absolutely necessary.
829829
if self.isManifestSandboxEnabled {
830830
let cacheDirectories = [self.databaseCacheDir, moduleCachePath].compactMap{ $0 }
831-
let strictness: Sandbox.Strictness = toolsVersion < .v5_3 ? .manifest_pre_53 : .default
832-
cmd = Sandbox.apply(command: cmd, strictness: strictness, writableDirectories: cacheDirectories)
831+
let sandboxProfile = SandboxProfile(pathRules: cacheDirectories.map{ .writable($0) })
832+
cmd = sandboxProfile.apply(to: cmd)
833833
}
834834

835835
// Run the compiled manifest.

Sources/Workspace/DefaultPluginScriptRunner.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,10 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner, Cancellable {
393393

394394
// Optionally wrap the command in a sandbox, which places some limits on what it can do. In particular, it blocks network access and restricts the paths to which the plugin can make file system changes. It does allow writing to temporary directories.
395395
if self.enableSandbox {
396-
command = Sandbox.apply(command: command, strictness: .writableTemporaryDirectory, writableDirectories: writableDirectories + [self.cacheDir], readOnlyDirectories: readOnlyDirectories)
396+
let sandboxProfile = SandboxProfile(allowWritingToTemporaryDirectories: true, pathRules:
397+
readOnlyDirectories.map{ .readonly($0) } +
398+
writableDirectories.map{ .writable($0) } + [.writable(self.cacheDir)])
399+
command = sandboxProfile.apply(to: command)
397400
}
398401

399402
// Create and configure a Process. We set the working directory to the cache directory, so that relative paths end up there.

Tests/BasicsTests/SandboxTests.swift

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,36 @@
1010
//
1111
//===----------------------------------------------------------------------===//
1212

13-
@testable import Basics
13+
import Basics
1414
import SPMTestSupport
1515
import TSCBasic
1616
import XCTest
1717

1818
final class SandboxTest: XCTestCase {
19+
20+
func testDefaults() throws {
21+
let sandboxProfile = SandboxProfile()
22+
XCTAssertEqual(sandboxProfile.allowNetwork, false)
23+
XCTAssertEqual(sandboxProfile.allowWritingToTemporaryDirectories, false)
24+
XCTAssertEqual(sandboxProfile.pathRules, [])
25+
}
26+
1927
func testSandboxOnAllPlatforms() throws {
2028
try withTemporaryDirectory { path in
21-
let command = Sandbox.apply(command: ["echo", "0"], strictness: .default, writableDirectories: [])
29+
let command = SandboxProfile().apply(to:
30+
["echo", "0"])
2231
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: command))
2332
}
2433
}
2534

2635
func testNetworkNotAllowed() throws {
2736
#if !os(macOS)
28-
try XCTSkipIf(true, "test is only supported on macOS")
37+
try XCTSkipIf(true, "sandboxing is only supported on macOS")
2938
#endif
3039

31-
let command = Sandbox.apply(command: ["ping", "-t", "1", "localhost"], strictness: .default, writableDirectories: [])
40+
/// Check that network access isn't allowed by default.
41+
let command = SandboxProfile().apply(to:
42+
["ping", "-t", "1", "localhost"])
3243

3344
XCTAssertThrowsError(try Process.checkNonZeroExit(arguments: command)) { error in
3445
guard case ProcessResult.Error.nonZeroExit(let result) = error else {
@@ -40,22 +51,24 @@ final class SandboxTest: XCTestCase {
4051

4152
func testWritableAllowed() throws {
4253
#if !os(macOS)
43-
try XCTSkipIf(true, "test is only supported on macOS")
54+
try XCTSkipIf(true, "sandboxing is only supported on macOS")
4455
#endif
4556

4657
try withTemporaryDirectory { path in
47-
let command = Sandbox.apply(command: ["touch", path.appending(component: UUID().uuidString).pathString], strictness: .default, writableDirectories: [path])
58+
let command = SandboxProfile(pathRules: [.writable(path)]).apply(to:
59+
["touch", path.appending(component: UUID().uuidString).pathString])
4860
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: command))
4961
}
5062
}
5163

5264
func testWritableNotAllowed() throws {
5365
#if !os(macOS)
54-
try XCTSkipIf(true, "test is only supported on macOS")
66+
try XCTSkipIf(true, "sandboxing is only supported on macOS")
5567
#endif
5668

5769
try withTemporaryDirectory { path in
58-
let command = Sandbox.apply(command: ["touch", path.appending(component: UUID().uuidString).pathString], strictness: .default, writableDirectories: [])
70+
let command = SandboxProfile().apply(to:
71+
["touch", path.appending(component: UUID().uuidString).pathString])
5972
XCTAssertThrowsError(try Process.checkNonZeroExit(arguments: command)) { error in
6073
guard case ProcessResult.Error.nonZeroExit(let result) = error else {
6174
return XCTFail("invalid error \(error)")
@@ -67,14 +80,15 @@ final class SandboxTest: XCTestCase {
6780

6881
func testRemoveNotAllowed() throws {
6982
#if !os(macOS)
70-
try XCTSkipIf(true, "test is only supported on macOS")
83+
try XCTSkipIf(true, "sandboxing is only supported on macOS")
7184
#endif
7285

7386
try withTemporaryDirectory { path in
7487
let file = path.appending(component: UUID().uuidString)
7588
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: ["touch", file.pathString]))
7689

77-
let command = Sandbox.apply(command: ["rm", file.pathString], strictness: .default, writableDirectories: [])
90+
let command = SandboxProfile().apply(to:
91+
["rm", file.pathString])
7892
XCTAssertThrowsError(try Process.checkNonZeroExit(arguments: command)) { error in
7993
guard case ProcessResult.Error.nonZeroExit(let result) = error else {
8094
return XCTFail("invalid error \(error)")
@@ -87,47 +101,51 @@ final class SandboxTest: XCTestCase {
87101
// FIXME: rdar://75707545 this should not be allowed outside very specific read locations
88102
func testReadAllowed() throws {
89103
#if !os(macOS)
90-
try XCTSkipIf(true, "test is only supported on macOS")
104+
try XCTSkipIf(true, "sandboxing is only supported on macOS")
91105
#endif
92106

93107
try withTemporaryDirectory { path in
94108
let file = path.appending(component: UUID().uuidString)
95109
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: ["touch", file.pathString]))
96110

97-
let command = Sandbox.apply(command: ["cat", file.pathString], strictness: .default, writableDirectories: [])
111+
let command = SandboxProfile().apply(to:
112+
["cat", file.pathString])
98113
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: command))
99114
}
100115
}
101116

102117
// FIXME: rdar://75707545 this should not be allowed outside very specific programs
103118
func testExecuteAllowed() throws {
104119
#if !os(macOS)
105-
try XCTSkipIf(true, "test is only supported on macOS")
120+
try XCTSkipIf(true, "sandboxing is only supported on macOS")
106121
#endif
107122

108123
try withTemporaryDirectory { path in
109-
let file = path.appending(component: UUID().uuidString)
110-
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: ["touch", file.pathString]))
111-
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: ["chmod", "+x", file.pathString]))
124+
let scriptFile = path.appending(component: UUID().uuidString)
125+
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: ["touch", scriptFile.pathString]))
126+
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: ["chmod", "+x", scriptFile.pathString]))
112127

113-
let command = Sandbox.apply(command: [file.pathString], strictness: .default, writableDirectories: [])
128+
let command = SandboxProfile().apply(to:
129+
[scriptFile.pathString])
114130
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: command))
115131
}
116132
}
117133

118134
func testWritingToTemporaryDirectoryAllowed() throws {
119135
#if !os(macOS)
120-
try XCTSkipIf(true, "test is only supported on macOS")
136+
try XCTSkipIf(true, "sandboxing is only supported on macOS")
121137
#endif
122138

123139
// Try writing to the per-user temporary directory, which is under /var/folders/.../TemporaryItems.
124140
let tmpFile1 = NSTemporaryDirectory() + "/" + UUID().uuidString
125-
let command1 = Sandbox.apply(command: ["touch", tmpFile1], strictness: .writableTemporaryDirectory)
141+
let command1 = SandboxProfile(allowWritingToTemporaryDirectories: true).apply(to:
142+
["touch", tmpFile1])
126143
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: command1))
127144
try? FileManager.default.removeItem(atPath: tmpFile1)
128145

129146
let tmpFile2 = "/tmp" + "/" + UUID().uuidString
130-
let command2 = Sandbox.apply(command: ["touch", tmpFile2], strictness: .writableTemporaryDirectory)
147+
let command2 = SandboxProfile(allowWritingToTemporaryDirectories: true).apply(to:
148+
["touch", tmpFile2])
131149
XCTAssertNoThrow(try Process.checkNonZeroExit(arguments: command2))
132150
try? FileManager.default.removeItem(atPath: tmpFile2)
133151
}

0 commit comments

Comments
 (0)