fix(macos): align shell script allowlist parity

This commit is contained in:
Nimrod Gutman 2026-03-19 15:18:17 +02:00
parent 66b5bfcc19
commit fbbf389359
4 changed files with 232 additions and 9 deletions

View File

@ -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
}

View File

@ -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 }

View File

@ -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()

View File

@ -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`() {