fix(macos): align shell script allowlist parity
This commit is contained in:
parent
66b5bfcc19
commit
fbbf389359
@ -5,12 +5,20 @@ enum ExecAllowlistMatcher {
|
||||
guard let resolution, !entries.isEmpty else { return nil }
|
||||
let rawExecutable = resolution.rawExecutable
|
||||
let resolvedPath = resolution.resolvedPath
|
||||
let scriptCandidatePath = resolution.scriptCandidatePath
|
||||
|
||||
for entry in entries {
|
||||
switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) {
|
||||
case let .valid(pattern):
|
||||
let target = resolvedPath ?? rawExecutable
|
||||
if self.matches(pattern: pattern, target: target) { return entry }
|
||||
let primaryTarget = resolvedPath ?? rawExecutable
|
||||
if self.matches(pattern: pattern, target: primaryTarget) {
|
||||
return entry
|
||||
}
|
||||
if let scriptCandidatePath,
|
||||
self.matches(pattern: pattern, target: scriptCandidatePath)
|
||||
{
|
||||
return entry
|
||||
}
|
||||
case .invalid:
|
||||
continue
|
||||
}
|
||||
|
||||
@ -3,9 +3,26 @@ import Foundation
|
||||
struct ExecCommandResolution {
|
||||
let rawExecutable: String
|
||||
let resolvedPath: String?
|
||||
let scriptCandidatePath: String?
|
||||
let executableName: String
|
||||
let cwd: String?
|
||||
|
||||
private static let maxAllowAlwaysTraversalDepth = 2
|
||||
|
||||
init(
|
||||
rawExecutable: String,
|
||||
resolvedPath: String?,
|
||||
scriptCandidatePath: String? = nil,
|
||||
executableName: String,
|
||||
cwd: String?)
|
||||
{
|
||||
self.rawExecutable = rawExecutable
|
||||
self.resolvedPath = resolvedPath
|
||||
self.scriptCandidatePath = scriptCandidatePath
|
||||
self.executableName = executableName
|
||||
self.cwd = cwd
|
||||
}
|
||||
|
||||
static func resolve(
|
||||
command: [String],
|
||||
rawCommand: String?,
|
||||
@ -22,10 +39,18 @@ struct ExecCommandResolution {
|
||||
let normalizedToken = ExecWrapperResolution.normalizeExecutableToken(token)
|
||||
let normalizedEffective = ExecWrapperResolution.normalizeExecutableToken(effectiveRaw)
|
||||
if normalizedToken == normalizedEffective {
|
||||
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
||||
let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
||||
return self.attachingScriptCandidatePath(
|
||||
to: resolution,
|
||||
command: command,
|
||||
cwd: cwd)
|
||||
}
|
||||
}
|
||||
return self.resolveExecutable(rawExecutable: effectiveRaw, cwd: cwd, env: env)
|
||||
let resolution = self.resolveExecutable(rawExecutable: effectiveRaw, cwd: cwd, env: env)
|
||||
return self.attachingScriptCandidatePath(
|
||||
to: resolution,
|
||||
command: command,
|
||||
cwd: cwd)
|
||||
}
|
||||
|
||||
static func resolveForAllowlist(
|
||||
@ -105,7 +130,11 @@ struct ExecCommandResolution {
|
||||
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||
let resolution = self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||
return self.attachingScriptCandidatePath(
|
||||
to: resolution,
|
||||
command: command,
|
||||
cwd: cwd)
|
||||
}
|
||||
|
||||
private static func resolveExecutable(
|
||||
@ -162,7 +191,7 @@ struct ExecCommandResolution {
|
||||
patterns: inout [String],
|
||||
seen: inout Set<String>)
|
||||
{
|
||||
guard depth <= ExecWrapperResolution.maxWrapperDepth, !command.isEmpty else {
|
||||
guard depth <= Self.maxAllowAlwaysTraversalDepth, !command.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
@ -226,6 +255,13 @@ struct ExecCommandResolution {
|
||||
return
|
||||
}
|
||||
|
||||
if let scriptCandidatePath = ExecWrapperResolution.resolveShellWrapperScriptCandidatePath(command, cwd: cwd),
|
||||
seen.insert(scriptCandidatePath).inserted
|
||||
{
|
||||
patterns.append(scriptCandidatePath)
|
||||
return
|
||||
}
|
||||
|
||||
guard let resolution = self.resolve(command: command, cwd: cwd, env: env),
|
||||
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
|
||||
seen.insert(pattern).inserted
|
||||
@ -235,6 +271,25 @@ struct ExecCommandResolution {
|
||||
patterns.append(pattern)
|
||||
}
|
||||
|
||||
private static func attachingScriptCandidatePath(
|
||||
to resolution: ExecCommandResolution?,
|
||||
command: [String],
|
||||
cwd: String?) -> ExecCommandResolution?
|
||||
{
|
||||
guard let resolution else {
|
||||
return nil
|
||||
}
|
||||
guard let scriptCandidatePath = ExecWrapperResolution.resolveShellWrapperScriptCandidatePath(command, cwd: cwd) else {
|
||||
return resolution
|
||||
}
|
||||
return ExecCommandResolution(
|
||||
rawExecutable: resolution.rawExecutable,
|
||||
resolvedPath: resolution.resolvedPath,
|
||||
scriptCandidatePath: scriptCandidatePath,
|
||||
executableName: resolution.executableName,
|
||||
cwd: resolution.cwd)
|
||||
}
|
||||
|
||||
private static func parseFirstToken(_ command: String) -> String? {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
@ -79,6 +79,15 @@ enum ExecWrapperResolution {
|
||||
|
||||
private static let shellMultiplexerWrapperNames = Set(["busybox", "toybox"])
|
||||
private static let transparentDispatchWrappers = Set(["nice", "nohup", "stdbuf", "timeout"])
|
||||
private static let shellWrapperOptionsWithValue = Set([
|
||||
"-c",
|
||||
"--command",
|
||||
"-o",
|
||||
"+o",
|
||||
"--rcfile",
|
||||
"--init-file",
|
||||
"--startup-file",
|
||||
])
|
||||
private static let niceOptionsWithValue = Set(["-n", "--adjustment", "--priority"])
|
||||
private static let stdbufOptionsWithValue = Set(["-i", "--input", "-o", "--output", "-e", "--error"])
|
||||
private static let timeoutFlagOptions = Set(["--foreground", "--preserve-status", "-v", "--verbose"])
|
||||
@ -285,6 +294,43 @@ enum ExecWrapperResolution {
|
||||
return current
|
||||
}
|
||||
|
||||
static func resolveShellWrapperScriptCandidatePath(_ argv: [String], cwd: String?) -> String? {
|
||||
let effective = self.unwrapShellInspectionArgv(argv)
|
||||
guard let token0 = self.trimmedNonEmpty(effective.first) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let normalized = self.normalizeExecutableToken(token0)
|
||||
guard let spec = self.findShellWrapperSpec(normalized) else {
|
||||
return nil
|
||||
}
|
||||
guard self.extractShellWrapperPayload(effective, spec: spec) == nil else {
|
||||
return nil
|
||||
}
|
||||
guard let scriptIndex = self.findShellWrapperScriptTokenIndex(effective) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let scriptToken = effective[scriptIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !scriptToken.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let expanded = scriptToken.hasPrefix("~")
|
||||
? (scriptToken as NSString).expandingTildeInPath
|
||||
: scriptToken
|
||||
if expanded.hasPrefix("/") {
|
||||
return expanded
|
||||
}
|
||||
|
||||
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let root = (trimmedCwd?.isEmpty == false) ? trimmedCwd! : FileManager().currentDirectoryPath
|
||||
return URL(fileURLWithPath: root)
|
||||
.appendingPathComponent(expanded)
|
||||
.standardizedFileURL
|
||||
.path
|
||||
}
|
||||
|
||||
static func hasEnvManipulationBeforeShellWrapper(_ argv: [String]) -> Bool {
|
||||
self.hasEnvManipulationBeforeShellWrapperInternal(
|
||||
argv,
|
||||
@ -456,6 +502,43 @@ enum ExecWrapperResolution {
|
||||
self.shellWrapperSpecs.first { $0.names.contains(baseExecutable) }
|
||||
}
|
||||
|
||||
private static func findShellWrapperScriptTokenIndex(_ argv: [String]) -> Int? {
|
||||
guard argv.count >= 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var idx = 1
|
||||
while idx < argv.count {
|
||||
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
let lower = token.lowercased()
|
||||
if lower == "--" {
|
||||
idx += 1
|
||||
break
|
||||
}
|
||||
if lower == "-c" || lower == "--command" || self.isCombinedShellModeFlag(lower, flag: "c") {
|
||||
return nil
|
||||
}
|
||||
if lower == "-s" || self.isCombinedShellModeFlag(lower, flag: "s") {
|
||||
return nil
|
||||
}
|
||||
if self.shellWrapperOptionsWithValue.contains(lower) {
|
||||
idx += 2
|
||||
continue
|
||||
}
|
||||
if token.hasPrefix("-") || token.hasPrefix("+") {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return idx < argv.count ? idx : nil
|
||||
}
|
||||
|
||||
private static func extractShellWrapperPayload(_ argv: [String], spec: ShellWrapperSpec) -> String? {
|
||||
switch spec.kind {
|
||||
case .posix:
|
||||
@ -629,6 +712,17 @@ enum ExecWrapperResolution {
|
||||
return commandIndex + 1
|
||||
}
|
||||
|
||||
private static func isCombinedShellModeFlag(_ lowerToken: String, flag: Character) -> Bool {
|
||||
let chars = Array(lowerToken)
|
||||
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
|
||||
return false
|
||||
}
|
||||
if chars.dropFirst().contains("-") {
|
||||
return false
|
||||
}
|
||||
return chars.dropFirst().contains(flag)
|
||||
}
|
||||
|
||||
private static func extractCmdInlineCommand(_ argv: [String]) -> String? {
|
||||
guard let idx = argv.firstIndex(where: {
|
||||
let token = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
|
||||
@ -59,6 +59,18 @@ struct ExecAllowlistTests {
|
||||
cwd: nil)
|
||||
}
|
||||
|
||||
private static func shellScriptFixture() throws -> (dir: URL, bash: URL, script: URL) {
|
||||
let dir = try makeTempDirForTests()
|
||||
let bash = dir.appendingPathComponent("bash")
|
||||
try makeExecutableForTests(at: bash)
|
||||
|
||||
let scriptsDir = dir.appendingPathComponent("scripts", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: scriptsDir, withIntermediateDirectories: true)
|
||||
let script = scriptsDir.appendingPathComponent("save_crystal.sh")
|
||||
FileManager.default.createFile(atPath: script.path, contents: Data("echo ok\n".utf8))
|
||||
return (dir, bash, script)
|
||||
}
|
||||
|
||||
@Test func `match uses resolved path`() {
|
||||
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
|
||||
let resolution = Self.homebrewRGResolution()
|
||||
@ -211,6 +223,20 @@ struct ExecAllowlistTests {
|
||||
#expect(resolutions[0].executableName == "sh")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist attaches shell script candidate path`() throws {
|
||||
let fixture = try Self.shellScriptFixture()
|
||||
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: ["bash", "scripts/save_crystal.sh"],
|
||||
rawCommand: nil,
|
||||
cwd: fixture.dir.path,
|
||||
env: ["PATH": "\(fixture.dir.path):/usr/bin:/bin"])
|
||||
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == fixture.bash.path)
|
||||
#expect(resolutions[0].scriptCandidatePath == fixture.script.path)
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env shell wrapper chains`() {
|
||||
let command = [
|
||||
"/usr/bin/env",
|
||||
@ -367,6 +393,22 @@ struct ExecAllowlistTests {
|
||||
#expect(evaluation.allowlistResolutions[0].executableName == "printf")
|
||||
}
|
||||
|
||||
@Test func `allowlist matcher falls back to shell script candidate path`() throws {
|
||||
let fixture = try Self.shellScriptFixture()
|
||||
|
||||
let resolution = ExecCommandResolution.resolveForAllowlist(
|
||||
command: ["/usr/bin/nice", "bash", "scripts/save_crystal.sh"],
|
||||
rawCommand: nil,
|
||||
cwd: fixture.dir.path,
|
||||
env: ["PATH": "\(fixture.dir.path):/usr/bin:/bin"]).first
|
||||
|
||||
let match = ExecAllowlistMatcher.match(
|
||||
entries: [ExecAllowlistEntry(pattern: fixture.script.path)],
|
||||
resolution: resolution)
|
||||
|
||||
#expect(match?.pattern == fixture.script.path)
|
||||
}
|
||||
|
||||
@Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() {
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
|
||||
@ -390,6 +432,30 @@ struct ExecAllowlistTests {
|
||||
#expect(!patterns.contains("/usr/bin/nice"))
|
||||
}
|
||||
|
||||
@Test func `allow always patterns persist shell script paths without inline commands`() throws {
|
||||
let fixture = try Self.shellScriptFixture()
|
||||
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: ["bash", "scripts/save_crystal.sh"],
|
||||
cwd: fixture.dir.path,
|
||||
env: ["PATH": "\(fixture.dir.path):/usr/bin:/bin"])
|
||||
|
||||
#expect(patterns == [fixture.script.path])
|
||||
#expect(!patterns.contains(fixture.bash.path))
|
||||
}
|
||||
|
||||
@Test func `allow always patterns persist shell script paths through dispatch wrappers`() throws {
|
||||
let fixture = try Self.shellScriptFixture()
|
||||
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: ["/usr/bin/nice", "bash", "scripts/save_crystal.sh"],
|
||||
cwd: fixture.dir.path,
|
||||
env: ["PATH": "\(fixture.dir.path):/usr/bin:/bin"])
|
||||
|
||||
#expect(patterns == [fixture.script.path])
|
||||
#expect(!patterns.contains(fixture.bash.path))
|
||||
}
|
||||
|
||||
@Test func `allow always patterns unwrap busybox shell applets to inner executables`() throws {
|
||||
let tmp = try makeTempDirForTests()
|
||||
let busybox = tmp.appendingPathComponent("busybox")
|
||||
@ -428,17 +494,17 @@ struct ExecAllowlistTests {
|
||||
#expect(patterns.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `allow always patterns support max transparent wrapper depth`() throws {
|
||||
@Test func `allow always patterns stop at shared transparent wrapper depth limit`() throws {
|
||||
let tmp = try makeTempDirForTests()
|
||||
let whoami = tmp.appendingPathComponent("whoami")
|
||||
try makeExecutableForTests(at: whoami)
|
||||
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: ["nice", "nohup", "timeout", "5", "stdbuf", "-o", "L", "whoami"],
|
||||
command: ["nice", "nohup", "timeout", "5", "whoami"],
|
||||
cwd: tmp.path,
|
||||
env: ["PATH": "\(tmp.path):/usr/bin:/bin"])
|
||||
|
||||
#expect(patterns == [whoami.path])
|
||||
#expect(patterns.isEmpty)
|
||||
}
|
||||
|
||||
@Test func `match all requires every segment to match`() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user