use std::path::{Path, PathBuf};
use crate::commands::hook_rewrite::strip_wrapper_prefix;
use crate::commands::pm_guard_bash::build_lease_cwd::{BuildDir, build_dir};
use crate::commands::pm_guard_bash::{
RedirectRole, evaluate_credential_print_command, input_redirect_operand, redirect_role,
split_heredoc_bodies, split_shell_segments, tokenize,
};
use crate::commands::pm_guard_secret_env_files::evaluate_env_plist_read;
use crate::commands::pm_guard_secret_nested::evaluate_nested_secret_read_rules;
use crate::commands::pm_guard_secret_read::{
Scan, command_basename, evaluate_secret_file_read_command, secret_files_named_in,
};
pub(crate) use crate::commands::pm_guard_secret_script_read::{Body, Unread, read_script};
use crate::commands::pm_guard_secret_script_self::resolve_self_paths;
pub(crate) const MAX_SCRIPT_DEPTH: usize = 4;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Family {
Shell,
Python,
Node,
Ruby,
Perl,
}
impl Family {
fn inline_letters(self) -> &'static [char] {
match self {
Family::Shell => &['c', 's'],
Family::Python => &['c', 'm'],
Family::Node => &['e', 'p'],
Family::Ruby => &['e'],
Family::Perl => &['e', 'E'],
}
}
fn valued_letters(self) -> &'static [char] {
match self {
Family::Shell => &['o', 'O'],
Family::Python => &['W', 'X'],
Family::Node => &['r'],
Family::Ruby => &['I', 'r', 'C', 'E', 'F', 'K', 'x', '0'],
Family::Perl => &['I', 'M', 'm', 'i', 'l', '0', 'C', 'F', 'd', 'D', 'x'],
}
}
fn code_loading_options(self) -> &'static [&'static str] {
match self {
Family::Shell => &["--rcfile", "--init-file"],
Family::Node => &[
"-r",
"--require",
"--import",
"--loader",
"--experimental-loader",
],
Family::Ruby => &["-r"],
Family::Python | Family::Perl => &[],
}
}
}
fn family_of(program: &str) -> Option<Family> {
let versioned_python = program
.strip_prefix("python")
.is_some_and(|v| v.is_empty() || v.chars().all(|c| c.is_ascii_digit() || c == '.'));
match program {
"bash" | "sh" | "zsh" | "dash" | "ksh" | "source" | "." => Some(Family::Shell),
"node" | "nodejs" => Some(Family::Node),
"ruby" => Some(Family::Ruby),
"perl" => Some(Family::Perl),
_ if versioned_python => Some(Family::Python),
_ => None,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Role {
Positional,
Loaded,
Stdin,
}
#[derive(Debug)]
struct Candidate {
word: String,
role: Role,
interpreter: Option<String>,
}
pub(crate) fn evaluate_script_body_secret_read(command: &str, cwd: &Path) -> Option<String> {
let mut seen = Vec::new();
let bases = [cwd.to_path_buf()];
each_script(
command,
&bases,
true,
|word, found, interpreter, seg_bases| {
let rule = match found {
Ok(path) => judge_script(&path, interpreter, seg_bases, 0, &mut seen),
Err(why) => Some(Rule::Unread(why)),
};
rule.map(|rule| deny_reason(word, rule))
},
)
}
fn each_script<T>(
command: &str,
bases: &[PathBuf],
strict: bool,
mut judge: impl FnMut(&str, Result<PathBuf, Unread>, Option<&str>, &[PathBuf]) -> Option<T>,
) -> Option<T> {
let (argv_text, _) = split_heredoc_bodies(command);
for segment in split_shell_segments(&argv_text) {
let trimmed = segment.trim();
let Ok(argv) = tokenize(trimmed) else {
continue;
};
let (candidates, exact) = candidates(&argv);
if candidates.is_empty() {
continue;
}
let seg_bases = segment_bases(&argv_text, trimmed, bases);
for (word, path, interpreter) in locate(&candidates, &seg_bases) {
if let Some(answer) = judge(&word, Ok(path), interpreter.as_deref(), &seg_bases) {
return Some(answer);
}
}
if strict
&& exact
&& let Some((word, why)) = unresolved(&candidates, &seg_bases, command)
&& let Some(answer) = judge(&word, Err(why), None, &seg_bases)
{
return Some(answer);
}
}
None
}
fn unresolved(
candidates: &[Candidate],
bases: &[PathBuf],
command: &str,
) -> Option<(String, Unread)> {
let computed = |c: &Candidate| is_computed(&expand(&c.word, bases));
if let Some(c) = candidates
.iter()
.find(|c| c.role == Role::Loaded && computed(c))
{
return Some((c.word.clone(), Unread::Unresolvable));
}
let script = candidates
.iter()
.find(|c| c.role == Role::Positional)
.or_else(|| candidates.iter().find(|c| c.role == Role::Stdin))?;
if computed(script) {
let interpreted = script.interpreter.is_some();
return interpreted.then(|| (script.word.clone(), Unread::Unresolvable));
}
let any_exists = candidates
.iter()
.filter(|c| c.role != Role::Loaded)
.any(|c| !existing_paths(&c.word, bases).is_empty());
let named_once = command.matches(script.word.as_str()).count() <= 1;
(!any_exists && named_once).then(|| (script.word.clone(), Unread::Missing))
}
fn is_computed(word: &str) -> bool {
word.contains(['$', '`', '*', '?', '[', '{', '('])
}
fn expand(word: &str, bases: &[PathBuf]) -> String {
let swap = |dir: Option<&Path>, names: [&str; 2]| {
let dir = dir?;
names.iter().find_map(|name| {
let rest = word.strip_prefix(name)?;
(rest.is_empty() || rest.starts_with('/')).then(|| format!("{}{rest}", dir.display()))
})
};
let home = dirs::home_dir();
swap(home.as_deref(), ["$HOME", "${HOME}"])
.or_else(|| swap(bases.first().map(PathBuf::as_path), ["$PWD", "${PWD}"]))
.unwrap_or_else(|| word.to_string())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Rule {
CredentialCommand,
SecretFile,
NestedOrDump,
LaunchdPlist,
Unread(Unread),
}
fn judge_script(
path: &Path,
interpreter: Option<&str>,
bases: &[PathBuf],
depth: usize,
seen: &mut Vec<PathBuf>,
) -> Option<Rule> {
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if seen.contains(&key) {
return None;
}
seen.push(key);
let body = match read_script(path) {
Ok(Body::Script(body)) if depth < MAX_SCRIPT_DEPTH => body,
Ok(Body::Script(_)) => return Some(Rule::Unread(Unread::TooDeep)),
Ok(Body::Executable) => return None,
Err(why) => return Some(Rule::Unread(why)),
};
let interpreter = interpreter
.map(str::to_string)
.unwrap_or_else(|| shebang_interpreter(&body));
let cwd = bases.first().map_or(Path::new("/"), PathBuf::as_path);
let plain = without_comment_lines(&body);
if let Some(rule) = judge_text(&inline_equivalent(&interpreter, &plain), &plain, cwd) {
return Some(rule);
}
if family_of(&interpreter) != Some(Family::Shell) {
return None;
}
let nested = resolve_self_paths(&body, path);
each_script(
&nested,
bases,
true,
|_, inner, inner_interp, inner_bases| match inner {
Ok(inner) => judge_script(&inner, inner_interp, inner_bases, depth + 1, seen),
Err(why) => Some(Rule::Unread(why)),
},
)
}
fn judge_text(command: &str, body: &str, cwd: &Path) -> Option<Rule> {
if evaluate_credential_print_command(command).is_some() {
return Some(Rule::CredentialCommand);
}
if evaluate_secret_file_read_command(command).is_some()
&& secret_files_named_in(body, Scan::ProgramText)
.iter()
.any(|word| !word.contains(['[', ']', '{', '}']))
{
return Some(Rule::SecretFile);
}
if evaluate_nested_secret_read_rules(command).is_some() {
return Some(Rule::NestedOrDump);
}
let input = serde_json::json!({ "command": command });
evaluate_env_plist_read("Bash", Some(&input), cwd).map(|_| Rule::LaunchdPlist)
}
fn without_comment_lines(body: &str) -> String {
body.split_inclusive('\n')
.map(|line| {
let comment = line.trim_start().starts_with('#') && !line.contains("$(");
if comment {
if line.ends_with('\n') { "\n" } else { "" }
} else {
line
}
})
.collect::<String>()
.replace("\\\n", " ")
}
pub(crate) fn inline_equivalent(interpreter: &str, body: &str) -> String {
if family_of(interpreter) == Some(Family::Shell) {
return body.to_string();
}
let mut delimiter = String::from("TM_PM_GUARD_SCRIPT_BODY");
while body.lines().any(|line| line.trim() == delimiter) {
delimiter.push('_');
}
format!("{interpreter} <<'{delimiter}'\n{body}\n{delimiter}\n")
}
fn shebang_interpreter(body: &str) -> String {
let Some(line) = body.lines().next().and_then(|l| l.strip_prefix("#!")) else {
return "sh".to_string();
};
let mut words = line.split_whitespace().map(command_basename);
let mut program = words.next().unwrap_or_default();
if program == "env" {
program = words
.find(|w| !w.starts_with('-') && !w.contains('='))
.unwrap_or_default();
}
let plain = |c: char| c.is_ascii_alphanumeric() || "._-".contains(c);
if program.is_empty() || !program.chars().all(plain) {
"sh".to_string()
} else {
program
}
}
fn candidates(argv: &[String]) -> (Vec<Candidate>, bool) {
if let Some(first) = argv.first()
&& first.ends_with(')')
&& !first.contains('(')
{
return candidates(&argv[1..]);
}
let exact = strip_wrapper_prefix(argv);
let start = exact
.or_else(|| {
argv.iter()
.position(|t| family_of(&command_basename(t)).is_some())
})
.unwrap_or(0);
let Some(program) = argv.get(start) else {
return (Vec::new(), false);
};
let basename = command_basename(program);
let found = match family_of(&basename) {
Some(family) => {
let interpreter = if matches!(basename.as_str(), "source" | ".") {
"sh".to_string()
} else {
basename
};
interpreter_candidates(family, &interpreter, &argv[start + 1..])
}
None if program.contains('/') => vec![Candidate {
word: program.clone(),
role: Role::Positional,
interpreter: None,
}],
None => Vec::new(),
};
(found, exact.is_some())
}
fn interpreter_candidates(family: Family, interpreter: &str, args: &[String]) -> Vec<Candidate> {
let mut out = Vec::new();
let push = |out: &mut Vec<Candidate>, word: &str, role| {
out.push(Candidate {
word: word.to_string(),
role,
interpreter: Some(interpreter.to_string()),
});
};
let mut options_done = false;
let mut i = 0;
while let Some(token) = args.get(i) {
i += 1;
if token.starts_with("<<") {
i += usize::from(matches!(token.as_str(), "<<" | "<<-" | "<<<"));
continue;
}
if let Some((0, attached)) = input_redirect_operand(token) {
let word = if attached.is_empty() {
i += 1;
args.get(i - 1).map_or("", String::as_str)
} else {
attached
};
push(&mut out, word, Role::Stdin);
continue;
}
match redirect_role(token) {
RedirectRole::None => {}
RedirectRole::TargetFollows => {
i += 1;
continue;
}
_ => continue,
}
if options_done || !token.starts_with('-') {
push(&mut out, token, Role::Positional);
continue;
}
if token == "--" {
options_done = true;
} else if token == "-" || runs_inline(family, token) {
out.retain(|c| c.role != Role::Positional);
return out;
} else if family.code_loading_options().contains(&token.as_str()) {
if let Some(value) = args.get(i) {
push(&mut out, value, Role::Loaded);
}
i += 1;
} else if let Some((flag, value)) = token.split_once('=')
&& family.code_loading_options().contains(&flag)
{
push(&mut out, value, Role::Loaded);
}
}
out
}
fn runs_inline(family: Family, token: &str) -> bool {
if family == Family::Node {
return matches!(token, "-e" | "-p" | "--eval" | "--print")
|| token.starts_with("--eval=")
|| token.starts_with("--print=");
}
let Some(cluster) = token.strip_prefix('-').filter(|c| !c.starts_with('-')) else {
return false;
};
for c in cluster.chars() {
if family.valued_letters().contains(&c) {
return false;
}
if family.inline_letters().contains(&c) {
return true;
}
}
false
}
fn locate(candidates: &[Candidate], bases: &[PathBuf]) -> Vec<(String, PathBuf, Option<String>)> {
let mut out = Vec::new();
let mut found_positional = false;
for role in [Role::Loaded, Role::Positional, Role::Stdin] {
if role == Role::Stdin && found_positional {
break;
}
for c in candidates.iter().filter(|c| c.role == role) {
let paths = existing_paths(&c.word, bases);
if paths.is_empty() {
continue;
}
let interpreter = c.interpreter.clone();
out.extend(
paths
.into_iter()
.map(|p| (c.word.clone(), p, interpreter.clone())),
);
if role == Role::Positional {
found_positional = true;
break;
}
}
}
out
}
fn existing_paths(word: &str, bases: &[PathBuf]) -> Vec<PathBuf> {
let word = expand(word, bases);
let word = word.as_str();
if word.is_empty() || is_computed(word) {
return Vec::new();
}
let mut paths = Vec::new();
if let Some(rest) = word.strip_prefix("~/") {
paths.extend(dirs::home_dir().map(|h| h.join(rest)));
} else if Path::new(word).is_absolute() {
paths.push(PathBuf::from(word));
} else {
paths.extend(bases.iter().map(|b| b.join(word)));
}
paths.retain(|p| std::fs::symlink_metadata(p).is_ok());
paths.dedup();
paths
}
fn segment_bases(command: &str, segment: &str, bases: &[PathBuf]) -> Vec<PathBuf> {
let home = dirs::home_dir();
let mut out = bases.to_vec();
let mut offsets: Vec<usize> = command.match_indices(segment).map(|(i, _)| i).collect();
offsets.push(command.len());
for base in bases {
for &at in &offsets {
if let BuildDir::Pinned(dir) | BuildDir::Expected(dir) =
build_dir(command, at, Some(base), home.as_deref())
&& !out.contains(&dir)
{
out.push(dir);
}
}
}
out
}
fn deny_reason(word: &str, rule: Rule) -> String {
let what = match rule {
Rule::CredentialCommand => {
"its body, or a script it runs, runs a credential-reading command (a Keychain, \
token or secret CLI; #8596, #8248)"
}
Rule::SecretFile => "its body, or a script it runs, names a secret-bearing file (#7266)",
Rule::NestedOrDump => {
"its body, or a script it runs, reads a credential through a nested command or an \
environment dump (#8756)"
}
Rule::LaunchdPlist => {
"its body, or a script it runs, reads a credential-bearing launchd plist (#8523)"
}
Rule::Unread(why) => match why {
Unread::Unreadable => "the guard could not read its body, so it fails closed",
Unread::NotRegular => "it is not a regular file, so the guard fails closed",
Unread::TooDeep => {
"it runs scripts nested past the guard's depth bound, so the guard fails closed"
}
Unread::TooLarge => {
"its body is over the guard's 256 KiB read bound, so the guard fails closed"
}
Unread::NotText => "its body is not UTF-8 text, so the guard fails closed",
Unread::Missing => {
"it does not exist and nothing else in the command names it, so the guard \
cannot judge its body and fails closed"
}
Unread::Unresolvable => {
"its path is computed at run time (a variable, a substitution or a glob), so \
the guard cannot judge its body and fails closed"
}
},
};
format!(
"`tm hook --pm-guard` refused running the script `{word}` (#8879): {what}. A script \
gets the same credential-read refusal as the command written inline (owner rulings \
263 and 268), so moving a refused read into a script does not change the answer. \
Stop and report this refusal to the Architect; do not retry the read in another form."
)
}
#[cfg(test)]
#[path = "pm_guard_secret_script_tests.rs"]
mod tests;