Skip to content

Commit

Permalink
Let command plugins ask for network permissions
Browse files Browse the repository at this point in the history
This adds a new plugin permission that allows a command plugin to ask for networking permissions. The permission can distinguish between local and outgoing connections, as well as specifying a list or range of ports to allow. Similar to existing permissions, there's also a CLI option for allowing connections.

resolves #5489
  • Loading branch information
neonichu committed Feb 3, 2023
1 parent 466dfe3 commit ce08da5
Show file tree
Hide file tree
Showing 18 changed files with 387 additions and 31 deletions.
2 changes: 1 addition & 1 deletion Documentation/Plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ To list the plugins that are available within the context of a package, use the
❯ swift package plugin --list
```

Command plugins that need to write to the file system will cause SwiftPM to ask the user for approval if `swift package` is invoked from a console, or deny the request if it is not. Passing the `--allow-writing-to-package-directory` flag to the `swift package` invocation will allow the request without questions — this is particularly useful in a Continuous Integration environment.
Command plugins that need to write to the file system will cause SwiftPM to ask the user for approval if `swift package` is invoked from a console, or deny the request if it is not. Passing the `--allow-writing-to-package-directory` flag to the `swift package` invocation will allow the request without questions — this is particularly useful in a Continuous Integration environment. Similarly, the `--allowNetworkConnections` flag can be used to allow network connections without showing a prompt.

## Writing a Plugin

Expand Down
56 changes: 53 additions & 3 deletions Sources/Basics/Sandbox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,28 @@
import Foundation
import TSCBasic

public enum SandboxNetworkPermission {
case none
case local(ports: [UInt8])
case all(ports: [UInt8])

fileprivate var domain: String? {
switch self {
case .none: return nil
case .local: return "local"
case .all: return "*"
}
}

fileprivate var ports: [UInt8] {
switch self {
case .all(let ports): return ports
case .local(let ports): return ports
case .none: return []
}
}
}

public enum Sandbox {
/// Applies a sandbox invocation to the given command line (if the platform supports it),
/// and returns the modified command line. On platforms that don't support sandboxing, the
Expand All @@ -27,10 +49,11 @@ public enum Sandbox {
command: [String],
strictness: Strictness = .default,
writableDirectories: [AbsolutePath] = [],
readOnlyDirectories: [AbsolutePath] = []
readOnlyDirectories: [AbsolutePath] = [],
allowNetworkConnections: SandboxNetworkPermission = .none
) throws -> [String] {
#if os(macOS)
let profile = try macOSSandboxProfile(strictness: strictness, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories)
let profile = try macOSSandboxProfile(strictness: strictness, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories, allowNetworkConnections: allowNetworkConnections)
return ["/usr/bin/sandbox-exec", "-p", profile] + command
#else
// rdar://40235432, rdar://75636874 tracks implementing sandboxes for other platforms.
Expand Down Expand Up @@ -78,7 +101,8 @@ fileprivate let threadSafeDarwinCacheDirectories: [AbsolutePath] = {
fileprivate func macOSSandboxProfile(
strictness: Sandbox.Strictness,
writableDirectories: [AbsolutePath],
readOnlyDirectories: [AbsolutePath]
readOnlyDirectories: [AbsolutePath],
allowNetworkConnections: SandboxNetworkPermission
) throws -> String {
var contents = "(version 1)\n"

Expand All @@ -95,6 +119,32 @@ fileprivate func macOSSandboxProfile(
// This is needed to launch any processes.
contents += "(allow process*)\n"

if let domain = allowNetworkConnections.domain {
// this is used by the system for caching purposes and will lead to log spew if not allowed
contents += "(allow file-write* (regex \"/Users/*/Library/Caches/*/Cache.db*\"))"

// this allows the specific network connections, as well as resolving DNS
contents += """
(system-network)
(allow network-outbound
(literal "/private/var/run/mDNSResponder")
"""

// this allows unix domain sockets
contents += "(remote unix-socket)"

allowNetworkConnections.ports.forEach { port in
contents += "(remote ip \"\(domain):\(port)\")"
}

// empty list of ports means all are permitted
if allowNetworkConnections.ports.isEmpty {
contents += "(remote ip \"\(domain):*\")"
}

contents += "\n)\n"
}

// The following accesses are only needed when interpreting the manifest (versus running a compiled version).
if strictness == .manifest_pre_53 {
// This is required by the Swift compiler.
Expand Down
117 changes: 94 additions & 23 deletions Sources/Commands/PackageTools/PluginCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//===----------------------------------------------------------------------===//

import ArgumentParser
import Basics
import CoreCommands
import Dispatch
import PackageGraph
Expand Down Expand Up @@ -38,6 +39,15 @@ struct PluginCommand: SwiftCommand {
@Option(name: .customLong("allow-writing-to-directory"),
help: "Allow the plugin to write to an additional directory")
var additionalAllowedWritableDirectories: [String] = []

enum NetworkPermission: String, EnumerableFlag, ExpressibleByArgument {
case none
case local
case all
}

@Option(name: .customLong("allow-network-connections"))
var allowNetworkConnections: NetworkPermission = .none
}

@OptionGroup()
Expand Down Expand Up @@ -116,38 +126,64 @@ struct PluginCommand: SwiftCommand {
// The `outputs` directory contains subdirectories for each combination of package and command plugin. Each usage of a plugin has an output directory that is writable by the plugin, where it can write additional files, and to which it can configure tools to write their outputs, etc.
let outputDir = pluginsDir.appending(component: "outputs")

var allowNetworkConnections = SandboxNetworkPermission.init(options.allowNetworkConnections)
// Determine the set of directories under which plugins are allowed to write. We always include the output directory.
var writableDirectories = [outputDir]
if options.allowWritingToPackageDirectory {
writableDirectories.append(package.path)
}
else {
// If the plugin requires write permission but it wasn't provided, we ask the user for approval.
if case .command(_, let permissions) = plugin.capability {
for case PluginPermission.writeToPackageDirectory(let reason) in permissions {
let problem = "Plugin ‘\(plugin.name)’ wants permission to write to the package directory."
let reason = "Stated reason: “\(reason)”."
if swiftTool.outputStream.isTTY {
// We can ask the user directly, so we do so.
let query = "Allow this plugin to write to the package directory?"
swiftTool.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8)
swiftTool.outputStream.flush()
let answer = readLine(strippingNewline: true)
// Throw an error if we didn't get permission.
if answer?.lowercased() != "yes" {
throw ValidationError("Plugin was denied permission to write to the package directory.")
}
// Otherwise append the directory to the list of allowed ones.
writableDirectories.append(package.path)
}
else {
// We can't ask the user, so emit an error suggesting passing the flag.
let remedy = "Use `--allow-writing-to-package-directory` to allow this."
throw ValidationError([problem, reason, remedy].joined(separator: "\n"))

// If the plugin requires permissions, we ask the user for approval.
if case .command(_, let permissions) = plugin.capability {
try permissions.forEach {
let permissionString: String
let reasonString: String
let remedyOption: String

switch $0 {
case .writeToPackageDirectory(let reason):
guard !options.allowWritingToPackageDirectory else { return } // permission already granted
permissionString = "write to the package directory"
reasonString = reason
remedyOption = "--allow-writing-to-package-directory"
case .allowNetworkConnections(let scope, let reason):
guard scope != .none else { return } // no need to prompt
guard options.allowNetworkConnections != scope else { return } // permission already granted
let portsString = scope.ports.isEmpty ? "on all ports" : "on ports: \(scope.ports)"
permissionString = "allow \(scope.label) network connections \(portsString)"
reasonString = reason
remedyOption = "--allow-network-connections \(scope.label)"
}

let problem = "Plugin ‘\(plugin.name)’ wants permission to \(permissionString)."
let reason = "Stated reason: “\(reasonString)”."
if swiftTool.outputStream.isTTY {
// We can ask the user directly, so we do so.
let query = "Allow this plugin to \(permissionString)?"
swiftTool.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8)
swiftTool.outputStream.flush()
let answer = readLine(strippingNewline: true)
// Throw an error if we didn't get permission.
if answer?.lowercased() != "yes" {
throw StringError("Plugin was denied permission to \(permissionString).")
}
} else {
// We can't ask the user, so emit an error suggesting passing the flag.
let remedy = "Use `\(remedyOption)` to allow this."
throw StringError([problem, reason, remedy].joined(separator: "\n"))
}

switch $0 {
case .writeToPackageDirectory:
// Otherwise append the directory to the list of allowed ones.
writableDirectories.append(package.path)
case .allowNetworkConnections(let scope, _):
allowNetworkConnections = .init(scope)
break
}
}
}

for pathString in options.additionalAllowedWritableDirectories {
writableDirectories.append(try AbsolutePath(validating: pathString, relativeTo: swiftTool.originalWorkingDirectory))
}
Expand Down Expand Up @@ -187,6 +223,7 @@ struct PluginCommand: SwiftCommand {
accessibleTools: accessibleTools,
writableDirectories: writableDirectories,
readOnlyDirectories: readOnlyDirectories,
allowNetworkConnections: allowNetworkConnections,
pkgConfigDirectories: swiftTool.options.locations.pkgConfigDirectories,
fileSystem: swiftTool.fileSystem,
observabilityScope: swiftTool.observabilityScope,
Expand Down Expand Up @@ -223,3 +260,37 @@ extension PluginCommandIntent {
}
}
}

extension SandboxNetworkPermission {
init(_ scope: PluginNetworkPermissionScope) {
switch scope {
case .none: self = .none
case .local(let ports): self = .local(ports: ports)
case .all(let ports): self = .all(ports: ports)
}
}
}

extension PluginCommand.PluginOptions.NetworkPermission {
public static func == (lhs: PluginCommand.PluginOptions.NetworkPermission, rhs: PluginNetworkPermissionScope) -> Bool {
switch rhs {
case .none: return lhs == .none
case .local: return lhs == .local
case .all: return lhs == .all
}
}

public static func != (lhs: PluginCommand.PluginOptions.NetworkPermission, rhs: PluginNetworkPermissionScope) -> Bool {
return !(lhs == rhs)
}
}

extension SandboxNetworkPermission {
init(_ permission: PluginCommand.PluginOptions.NetworkPermission) {
switch permission {
case .none: self = .none
case .local: self = .local(ports: [])
case .all: self = .all(ports: [])
}
}
}
2 changes: 1 addition & 1 deletion Sources/Commands/PackageTools/SwiftPackageTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ extension SwiftPackageTool {
else if matchingPlugins.count > 1 {
throw ValidationError("\(matchingPlugins.count) plugins found for '\(command)'")
}

// At this point we know we found exactly one command plugin, so we run it.
try PluginCommand.run(
plugin: matchingPlugins[0],
Expand Down
20 changes: 20 additions & 0 deletions Sources/Commands/Utilities/DescribedPackage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,14 +186,34 @@ struct DescribedPackage: Encodable {
}

struct Permission: Encodable {
enum NetworkScope: Encodable {
case none
case local(ports: [UInt8])
case all(ports: [UInt8])

init(_ scope: PluginNetworkPermissionScope) {
switch scope {
case .none: self = .none
case .local(let ports): self = .local(ports: ports)
case .all(let ports): self = .all(ports: ports)
}
}
}

let type: String
let reason: String
let networkScope: NetworkScope

init(from permission: PackageModel.PluginPermission) {
switch permission {
case .writeToPackageDirectory(let reason):
self.type = "writeToPackageDirectory"
self.reason = reason
self.networkScope = .none
case .allowNetworkConnections(let scope, let reason):
self.type = "allowNetworkConnections"
self.reason = reason
self.networkScope = .init(scope)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Create a Permission

- ``allowNetworkConnections(scope:reason:)``
- ``writeToPackageDirectory(reason:)``

### Encoding and Decoding
Expand Down
27 changes: 26 additions & 1 deletion Sources/PackageDescription/PackageDescriptionSerialization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -298,13 +298,33 @@ extension PluginCommandIntent: Encodable {
}
}

extension PluginNetworkPermissionScope: Encodable {
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .all: try container.encode("all")
case .local: try container.encode("local")
case .none: try container.encode("none")
}
}

var ports: [UInt8] {
switch self {
case .all(let ports): return ports
case .local(let ports): return ports
case .none: return []
}
}
}

/// `Encodable` conformance.
extension PluginPermission: Encodable {
private enum CodingKeys: CodingKey {
case type, reason
case type, reason, scope, ports
}

private enum PermissionType: String, Encodable {
case allowNetworkConnections
case writeToPackageDirectory
}

Expand All @@ -314,6 +334,11 @@ extension PluginPermission: Encodable {
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case ._allowNetworkConnections(let scope, let reason):
try container.encode(PermissionType.allowNetworkConnections, forKey: .type)
try container.encode(reason, forKey: .reason)
try container.encode(scope, forKey: .scope)
try container.encode(scope.ports, forKey: .ports)
case ._writeToPackageDirectory(let reason):
try container.encode(PermissionType.writeToPackageDirectory, forKey: .type)
try container.encode(reason, forKey: .reason)
Expand Down
Loading

0 comments on commit ce08da5

Please sign in to comment.