Files
Termind/Frameworks/TermindDomain/Shell/CommandRiskEngine.swift
T

461 lines
20 KiB
Swift

import Foundation
nonisolated enum CommandRiskEngine {
private static let maximumCommandLength = 32_768
private static let maximumRecursionDepth = 8
static func assess(_ command: String) -> RiskAssessment {
assess(command, depth: 0)
}
private static func assess(_ command: String, depth: Int) -> RiskAssessment {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .safe }
guard trimmed.count <= maximumCommandLength, depth <= maximumRecursionDepth else {
return RiskAssessment(
risk: .caution,
reasons: [String(localized: "Command analysis exceeded the supported complexity limit.")]
)
}
if isForkBomb(trimmed) {
return RiskAssessment(
risk: .destructive,
reasons: [String(localized: "Matches a fork-bomb pattern.")]
)
}
let parsed = ShellCommandSyntax.parse(trimmed)
var result = RiskAssessment.safe
if parsed.isAmbiguous {
result.merge(
RiskAssessment(
risk: .caution,
reasons: [String(localized: "Contains shell syntax that cannot be proven read-only.")]
))
}
for substitution in parsed.substitutions {
result.merge(assess(substitution, depth: depth + 1))
}
for pipeline in parsed.pipelineGroups where downloadsIntoInterpreter(pipeline) {
result.merge(
RiskAssessment(
risk: .destructive,
reasons: [
String(
localized:
"Pipes downloaded content straight into an interpreter — this executes untrusted remote code."
)
]
))
}
for command in parsed.commands {
result.merge(assessSingle(command, depth: depth))
}
for redirection in parsed.redirections {
result.merge(redirectionAssessment(redirection))
}
return result
}
private static func assessSingle(_ command: String, depth: Int) -> RiskAssessment {
let tokenized = ShellCommandSyntax.tokenize(command)
var tokens = tokenized.values
var result = RiskAssessment.safe
if tokenized.isAmbiguous {
result.merge(
RiskAssessment(
risk: .caution,
reasons: [String(localized: "Contains shell syntax that cannot be proven read-only.")]
))
}
while let first = tokens.first, ShellCommandSyntax.isEnvironmentAssignment(first) {
tokens.removeFirst()
}
guard let head = tokens.first else { return result }
let name = ShellCommandSyntax.executableName(head)
let args = Array(tokens.dropFirst())
if CommandRiskRules.isKnown(name), CommandRiskRules.isStandaloneHelpOrVersionRequest(args) {
return result
}
if let control = controlBody(name: name, args: args) {
result.merge(assess(control, depth: depth + 1))
return result
}
if let wrapped = wrappedCommand(name: name, args: args) {
result.merge(wrapped.floor)
guard let child = wrapped.child, !child.isEmpty else { return result }
result.merge(assess(child, depth: depth + 1))
return result
}
result.merge(CommandRiskRules.classify(name: name, args: args))
if name == "find" {
for child in findExecutedCommands(args) {
result.merge(assess(child, depth: depth + 1))
}
} else if ["docker", "podman", "nerdctl"].contains(name), let child = containerExecCommand(args) {
result.merge(assess(child, depth: depth + 1))
} else if ["kubectl", "oc"].contains(name), let child = kubernetesExecCommand(args) {
result.merge(assess(child, depth: depth + 1))
}
return result
}
private static func controlBody(name: String, args: [String]) -> String? {
let commandIntroducers: Set<String> = ["if", "then", "elif", "while", "until", "do", "else", "!"]
guard commandIntroducers.contains(name), !args.isEmpty else { return nil }
return args.joined(separator: " ")
}
private static func wrappedCommand(name: String, args: [String]) -> WrappedCommand? {
switch name {
case "sudo":
let child = childAfterOptions(
args,
flags: ["-A", "-b", "-E", "-e", "-H", "-i", "-K", "-k", "-n", "-P", "-S", "-s", "-V"],
valueOptions: ["-C", "-D", "-g", "-h", "-p", "-R", "-r", "-T", "-t", "-U", "-u"]
)
return WrappedCommand(
child: child?.joined(separator: " "),
floor: RiskAssessment(
risk: .caution, reasons: [String(localized: "Runs with elevated privileges.")])
)
case "doas":
let child = childAfterOptions(args, flags: ["-L", "-n", "-s"], valueOptions: ["-C", "-u"])
return WrappedCommand(
child: child?.joined(separator: " "),
floor: RiskAssessment(
risk: .caution, reasons: [String(localized: "Runs with elevated privileges.")])
)
case "env":
if let splitIndex = args.firstIndex(where: {
$0 == "-S" || $0 == "--split-string" || $0.hasPrefix("--split-string=")
}) {
let option = args[splitIndex]
let splitCommand: String
if let equals = option.firstIndex(of: "=") {
splitCommand = String(option[option.index(after: equals)...])
} else if args.indices.contains(splitIndex + 1) {
splitCommand = args[splitIndex + 1]
} else {
return WrappedCommand(child: nil, floor: .ambiguous)
}
return WrappedCommand(child: splitCommand, floor: .safe)
}
guard
var child = childAfterOptions(
args,
flags: ["-0", "-i", "--ignore-environment", "--null"],
valueOptions: ["-C", "-S", "-u", "--chdir", "--split-string", "--unset"]
)
else {
return WrappedCommand(child: nil, floor: .ambiguous)
}
while let first = child.first, ShellCommandSyntax.isEnvironmentAssignment(first) {
child.removeFirst()
}
return WrappedCommand(child: child.joined(separator: " "), floor: .safe)
case "command", "builtin":
if args.contains("-v") || args.contains("-V") { return nil }
let child = childAfterOptions(args, flags: ["-p"], valueOptions: [])
return WrappedCommand(child: child?.joined(separator: " "), floor: .safe)
case "exec":
let child = childAfterOptions(args, flags: ["-c", "-l"], valueOptions: ["-a"])
return WrappedCommand(child: child?.joined(separator: " "), floor: .safe)
case "nice":
let child = childAfterOptions(
args, flags: [], valueOptions: ["-n", "--adjustment"], acceptsNumericFlags: true)
return WrappedCommand(child: child?.joined(separator: " "), floor: .safe)
case "ionice":
let targetsExistingProcess = args.contains(where: {
["-p", "-P", "-u", "--pid", "--pgid", "--uid"].contains($0)
|| $0.hasPrefix("--pid=") || $0.hasPrefix("--pgid=") || $0.hasPrefix("--uid=")
})
if targetsExistingProcess {
let changesPriority = args.contains(where: {
["-c", "-n", "--class", "--classdata"].contains($0)
|| $0.hasPrefix("--class=") || $0.hasPrefix("--classdata=")
})
return WrappedCommand(child: nil, floor: changesPriority ? .ambiguous : .safe)
}
let child = childAfterOptions(
args,
flags: ["-t", "--ignore", "--classdata", "-h"],
valueOptions: ["-c", "-n", "-p", "-P", "-u", "--class", "--classdata", "--pid", "--pgid", "--uid"]
)
return WrappedCommand(child: child?.joined(separator: " "), floor: .safe)
case "timeout":
guard
var remaining = childAfterOptions(
args,
flags: ["--foreground", "--preserve-status", "-v", "--verbose"],
valueOptions: ["-k", "-s", "--kill-after", "--signal"]
), !remaining.isEmpty
else {
return WrappedCommand(child: nil, floor: .ambiguous)
}
remaining.removeFirst()
return WrappedCommand(child: remaining.joined(separator: " "), floor: .safe)
case "stdbuf":
let child = childAfterOptions(
args, flags: [], valueOptions: ["-e", "-i", "-o", "--error", "--input", "--output"])
return WrappedCommand(child: child?.joined(separator: " "), floor: .safe)
case "setsid":
let child = childAfterOptions(
args, flags: ["-c", "-f", "-w", "--ctty", "--fork", "--wait"], valueOptions: [])
return WrappedCommand(child: child?.joined(separator: " "), floor: .safe)
case "nohup":
let child = childAfterOptions(args, flags: [], valueOptions: [])
return WrappedCommand(child: child?.joined(separator: " "), floor: .safe)
case "watch":
let child = childAfterOptions(
args,
flags: [
"-b", "-d", "-e", "-g", "-t", "-x", "--beep", "--differences", "--errexit", "--chgexit",
"--no-title", "--exec",
],
valueOptions: ["-n", "-p", "--interval", "--precise"]
)
return WrappedCommand(child: child?.joined(separator: " "), floor: .safe)
case "busybox", "toybox":
let child = childAfterOptions(args, flags: [], valueOptions: [])
return WrappedCommand(child: child?.joined(separator: " "), floor: .safe)
case "xargs":
let child = childAfterOptions(
args,
flags: ["-0", "-p", "-r", "-t", "-x", "--null", "--no-run-if-empty", "--verbose"],
valueOptions: [
"-a", "-d", "-E", "-I", "-L", "-n", "-P", "-s", "--arg-file", "--delimiter",
"--eof", "--replace", "--max-lines", "--max-args", "--max-procs", "--max-chars",
]
)
return WrappedCommand(child: child?.joined(separator: " ") ?? "echo", floor: .safe)
case "parallel":
let child = childAfterOptions(
args,
flags: ["-k", "--keep-order", "--line-buffer", "--will-cite"],
valueOptions: ["-j", "-S", "--jobs", "--sshlogin", "--tagstring"]
)
return WrappedCommand(child: child?.joined(separator: " "), floor: .ambiguous)
case "eval":
return WrappedCommand(child: args.joined(separator: " "), floor: .safe)
case "sh", "bash", "zsh", "dash", "fish", "ksh":
guard
let commandIndex = args.firstIndex(where: { argument in
argument == "-c"
|| (argument.hasPrefix("-") && !argument.hasPrefix("--") && argument.dropFirst().contains("c"))
}), args.indices.contains(commandIndex + 1)
else {
return WrappedCommand(child: nil, floor: .ambiguous)
}
return WrappedCommand(child: args[commandIndex + 1], floor: .safe)
case "ssh":
guard
var remaining = childAfterOptions(
args,
flags: [
"-4", "-6", "-A", "-a", "-C", "-f", "-G", "-g", "-K", "-k", "-M", "-N", "-n", "-q", "-s", "-T",
"-t", "-V", "-v", "-X", "-x", "-Y", "-y",
],
valueOptions: [
"-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m",
"-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w",
]
), !remaining.isEmpty
else {
return WrappedCommand(child: nil, floor: .remote)
}
remaining.removeFirst()
return WrappedCommand(child: remaining.joined(separator: " "), floor: .remote)
default:
return nil
}
}
private static func childAfterOptions(
_ args: [String],
flags: Set<String>,
valueOptions: Set<String>,
acceptsNumericFlags: Bool = false
) -> [String]? {
var index = 0
while index < args.count {
let argument = args[index]
if argument == "--" { return Array(args.dropFirst(index + 1)) }
guard argument.hasPrefix("-"), argument != "-" else { return Array(args.dropFirst(index)) }
if acceptsNumericFlags, Int(argument) != nil {
index += 1
continue
}
let optionName = String(argument.split(separator: "=", maxSplits: 1).first ?? Substring(argument))
if flags.contains(optionName) {
index += 1
continue
}
if valueOptions.contains(optionName) {
index += argument.contains("=") ? 1 : 2
if index > args.count { return nil }
continue
}
if argument.hasPrefix("-"), !argument.hasPrefix("--") {
let optionCharacters = Array(argument.dropFirst())
var consumed = false
for (offset, character) in optionCharacters.enumerated() {
let shortOption = "-\(character)"
if flags.contains(shortOption) { continue }
if valueOptions.contains(shortOption) {
index += offset == optionCharacters.count - 1 ? 2 : 1
if index > args.count { return nil }
consumed = true
break
}
return nil
}
if consumed { continue }
index += 1
continue
}
return nil
}
return []
}
private static func findExecutedCommands(_ args: [String]) -> [String] {
var commands: [String] = []
var index = 0
while index < args.count {
guard ["-exec", "-execdir", "-ok", "-okdir"].contains(args[index]) else {
index += 1
continue
}
let start = index + 1
var end = start
while end < args.count, args[end] != ";", args[end] != "+" { end += 1 }
if start < end { commands.append(args[start..<end].joined(separator: " ")) }
index = max(end + 1, start)
}
return commands
}
private static func containerExecCommand(_ args: [String]) -> String? {
guard let execIndex = args.firstIndex(of: "exec") else { return nil }
let execArguments = Array(args.dropFirst(execIndex + 1))
guard
var remaining = childAfterOptions(
execArguments,
flags: ["-d", "--detach", "-i", "--interactive", "--privileged", "-t", "--tty"],
valueOptions: ["-e", "--env", "--env-file", "--detach-keys", "-u", "--user", "-w", "--workdir"]
), !remaining.isEmpty
else {
return nil
}
remaining.removeFirst()
return remaining.joined(separator: " ")
}
private static func kubernetesExecCommand(_ args: [String]) -> String? {
let words = CommandRiskRules.operands(args, valueOptions: CommandRiskRules.kubernetesGlobalValueOptions)
guard words.first == "exec", let separator = args.firstIndex(of: "--"), args.indices.contains(separator + 1)
else {
return nil
}
return args.dropFirst(separator + 1).joined(separator: " ")
}
private static func redirectionAssessment(_ redirection: ShellOutputRedirection) -> RiskAssessment {
let target = redirection.target
let harmlessTargets: Set<String> = [
"/dev/null", "/dev/stdout", "/dev/stderr", "/dev/fd/1", "/dev/fd/2", "/proc/self/fd/1",
"/proc/self/fd/2",
]
if harmlessTargets.contains(target) { return .safe }
if target.hasPrefix("/dev/") || target.hasPrefix("/proc/") || target.hasPrefix("/sys/") {
return RiskAssessment(
risk: .destructive,
reasons: [String(localized: "Writes directly to a device or kernel control path.")]
)
}
if redirection.mode == .force {
return RiskAssessment(
risk: .destructive,
reasons: [String(localized: "Force-overwrites `\(target)`.")]
)
}
return RiskAssessment(
risk: .caution,
reasons: [String(localized: "Writes to `\(target)`.")]
)
}
private static func downloadsIntoInterpreter(_ stages: [String]) -> Bool {
guard stages.count > 1, let first = effectiveExecutable(in: stages[0]) else { return false }
let fetchers: Set<String> = ["curl", "wget", "fetch", "http", "https", "xh"]
let interpreters: Set<String> = [
"sh", "bash", "zsh", "fish", "dash", "ksh", "python", "python3", "perl", "ruby", "node",
"php", "lua", "Rscript", "deno", "bun", "pwsh",
]
guard fetchers.contains(first) else { return false }
return stages.dropFirst().contains { stage in
effectiveExecutable(in: stage).map(interpreters.contains) == true
}
}
private static func effectiveExecutable(in command: String) -> String? {
var tokens = ShellCommandSyntax.tokenize(command).values
while let first = tokens.first, ShellCommandSyntax.isEnvironmentAssignment(first) { tokens.removeFirst() }
var iterations = 0
while let first = tokens.first, iterations < maximumRecursionDepth {
let name = ShellCommandSyntax.executableName(first)
guard ["sudo", "doas", "env", "command", "exec", "nice", "stdbuf", "setsid", "nohup"].contains(name),
let wrapped = wrappedCommand(name: name, args: Array(tokens.dropFirst())),
let child = wrapped.child
else {
return name
}
tokens = ShellCommandSyntax.tokenize(child).values
iterations += 1
}
return tokens.first.map(ShellCommandSyntax.executableName)
}
private static func isForkBomb(_ command: String) -> Bool {
let squeezed = command.filter { !$0.isWhitespace }
guard let parenRange = squeezed.range(of: "(){") else { return false }
let name = String(squeezed[squeezed.startIndex..<parenRange.lowerBound])
return !name.isEmpty && squeezed.contains("\(name)|\(name)&")
}
private struct WrappedCommand {
var child: String?
var floor: RiskAssessment
}
}
nonisolated extension RiskAssessment {
static var safe: RiskAssessment { RiskAssessment(risk: .safe, reasons: []) }
static var ambiguous: RiskAssessment {
RiskAssessment(
risk: .caution,
reasons: [String(localized: "Contains shell syntax that cannot be proven read-only.")]
)
}
static var remote: RiskAssessment {
RiskAssessment(
risk: .caution,
reasons: [String(localized: "Runs a command on another host.")]
)
}
mutating func merge(_ other: RiskAssessment) {
risk = max(risk, other.risk)
for reason in other.reasons where !reasons.contains(reason) { reasons.append(reason) }
}
}