#[path = "credential_print_clis.rs"]
mod credential_print_clis;
#[path = "credential_print_heredoc.rs"]
mod credential_print_heredoc;
#[path = "credential_print_passes.rs"]
mod credential_print_passes;
#[path = "credential_print_programs.rs"]
mod credential_print_programs;
#[path = "credential_print_redirect.rs"]
mod credential_print_redirect;
#[path = "credential_print_split.rs"]
mod credential_print_split;
#[path = "credential_print_stderr.rs"]
mod credential_print_stderr;
#[path = "credential_print_taint.rs"]
mod credential_print_taint;
#[path = "credential_print_taint_forms.rs"]
mod credential_print_taint_forms;
#[path = "credential_print_taint_sinks.rs"]
mod credential_print_taint_sinks;
use super::bash_tokens::tokenize;
use super::shell_lex::{WrappedCommand, wrapped_command};
use crate::commands::program_word::resolve_program_word;
use credential_print_clis::{interactive_security, judge_cli_echoes, names_cli_trigger};
use credential_print_heredoc::strip_comments_and_heredocs;
pub(super) use credential_print_heredoc::{OperatorCtx, heredoc_operators};
pub(super) use credential_print_programs::{basename, code_operands, evaluator_name};
use credential_print_programs::{
consumes_stdin, credential_fds, enables_xtrace, first_credential_program, keyword_words,
};
pub(super) use credential_print_programs::is_evaluator;
pub(super) use credential_print_programs::COMPOUND_OPENERS;
pub(super) use credential_print_programs::KEYWORDS;
pub(super) use credential_print_taint::is_identifier;
use credential_print_passes::{MAX_PASSES, stale_after};
pub(crate) use credential_print_redirect::input_redirect_operand;
use credential_print_redirect::{
apply_redirections, changes_directory, redirect_target_sink, terminal_name_sink,
};
use credential_print_split::{lift_substitutions, split_stages, ungroup};
use credential_print_stderr::{reports_operand_on_stderr, route_print_unit};
use credential_print_taint::{bound_names, dumps_variables, expands_tainted};
use credential_print_taint_forms::{array_bindings, function_header_words, reads_in_arithmetic};
use credential_print_taint_sinks::{
judge_builtin_sinks, reads_script_by_path, reports_unset_error,
};
use std::cell::Cell;
use std::collections::BTreeSet;
use std::rc::Rc;
const TRIGGERS: &[&str] = &[
"find-generic-password",
"find-internet-password",
"dump-keychain",
"print-access-token",
"print-identity-token",
"config-helper",
];
const ARG_PRINTERS: &[&str] = &["echo", "printf", "print", "cat"];
const MAX_DEPTH: usize = 8;
const WORK_BUDGET: usize = 4_000;
const BYTES_PER_UNIT: usize = 1_024;
const MARK: &str = "__TMCRED";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Sink {
Terminal,
Captured,
Discarded,
Pipe,
Unknown,
}
#[derive(Debug, PartialEq, Eq)]
enum Refusal {
Prints,
Unreadable(&'static str),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SubKind {
Command,
Input,
Output,
}
#[derive(Clone, Copy)]
struct Sub {
kind: SubKind,
yields: bool,
}
#[derive(Clone, Copy)]
struct Heredoc {
program_text: bool,
}
#[derive(Clone, Default)]
struct Lifted {
subs: Vec<Sub>,
heredocs: Vec<Heredoc>,
names: BTreeSet<String>,
spent: Rc<Cell<usize>>,
passes: Rc<Cell<usize>>,
changes_dir: bool,
}
pub(crate) fn evaluate_credential_print_command(command: &str) -> Option<String> {
if !has_trigger(command) {
return None;
}
let result = std::panic::catch_unwind(|| {
scan(
command,
Sink::Terminal,
Sink::Terminal,
0,
&Lifted::default(),
)
})
.unwrap_or(Err(Refusal::Unreadable("it (the scanner failed)")));
let why = match result {
Ok(_) => return None,
Err(Refusal::Prints) => "it would print a credential value into the tool output, \
where it becomes transcript text"
.to_string(),
Err(Refusal::Unreadable(what)) => format!(
"it runs a credential-printing command and the guard cannot read {what}, so \
it cannot prove the value stays out of the tool output"
),
};
Some(format!(
"`tm hook --pm-guard` refused this command: {why} (#8596, #8248). {HOW_TO}"
))
}
const HOW_TO: &str = "Stop and report this refusal to the Architect, who decides \
whether the value is needed. Do not retry the read in another form: not in a script \
(the guard reads the body of a script a command runs, refuses a body that reads a \
secret the same way, and refuses a script whose body it cannot judge; #8879), a \
variable, a file or a different command (owner ruling 263). To check only that an \
entry exists, test the exit status alone \
(`security find-generic-password -s <service> >/dev/null 2>&1`, no `-w`/`-g`).";
fn has_trigger(text: &str) -> bool {
let flat: String = text
.chars()
.filter(|c| !matches!(c, '\'' | '"' | '\\'))
.collect::<String>()
.to_ascii_lowercase();
TRIGGERS.iter().any(|t| flat.contains(t)) || names_cli_trigger(&flat)
}
fn input_is_program_text(text: &str) -> bool {
has_trigger(text) || text.contains('$') || text.contains('`') || text.contains(MARK)
}
fn scan(
text: &str,
stdout: Sink,
stderr: Sink,
depth: usize,
outer: &Lifted,
) -> Result<bool, Refusal> {
if depth > MAX_DEPTH {
return Err(Refusal::Unreadable("nesting this deep"));
}
let mut names = outer.names.clone();
for _ in 0..MAX_PASSES {
if depth == 0 {
outer.passes.set(outer.passes.get() + 1);
}
let mut lifted = outer.clone();
lifted.names.clone_from(&names);
let (yields, bound, stale) = scan_pass(text, stdout, stderr, depth, &mut lifted)?;
let known = names.len();
names.extend(bound);
if names.len() == known || !stale {
return Ok(yields);
}
}
Err(Refusal::Unreadable(
"a command binding this many names late",
))
}
fn scan_pass(
text: &str,
stdout: Sink,
stderr: Sink,
depth: usize,
lifted: &mut Lifted,
) -> Result<(bool, Vec<String>, bool), Refusal> {
let text = strip_comments_and_heredocs(text, &mut lifted.heredocs);
lifted.changes_dir |= changes_directory(&text);
let flat = lift_substitutions(&text, stdout, stderr, depth, lifted)?;
let stages = split_stages(&flat);
let cost = 1 + text.len() / BYTES_PER_UNIT;
lifted.spent.set(lifted.spent.get() + cost);
if lifted.spent.get() > WORK_BUDGET * lifted.passes.get().max(1) {
return Err(Refusal::Unreadable("a command this costly to follow"));
}
let (mut yields, mut bound, mut first_bind) = (false, Vec::new(), None);
let (mut stdin_carries, mut stdin_text) = (false, false);
for (idx, (stage, piped, pipe_stderr)) in stages.iter().enumerate() {
let next_exists = stages.get(idx + 1).is_some();
let out = if *piped && next_exists {
Sink::Pipe
} else {
stdout
};
let err = if *pipe_stderr && next_exists {
Sink::Pipe
} else {
stderr
};
let ctx = StageCtx {
out,
err,
stdin_carries,
stdin_text,
stdin_piped: idx > 0 && stages[idx - 1].1,
depth,
};
let emitted = judge_stage(stage.trim(), lifted, ctx)?;
yields |= emitted.captured;
stdin_carries = emitted.piped;
stdin_text = *piped && emitted.text;
for name in emitted.bound {
if lifted.names.insert(name.clone()) {
first_bind.get_or_insert(idx);
bound.push(name);
}
}
}
let stale = first_bind.is_some_and(|at| stale_after(&flat, &stages, at));
Ok((yields, bound, stale))
}
#[derive(Clone, Copy)]
struct StageCtx {
out: Sink,
err: Sink,
stdin_carries: bool,
stdin_text: bool,
stdin_piped: bool,
depth: usize,
}
#[derive(Default)]
struct Emitted {
captured: bool,
piped: bool,
text: bool,
bound: Vec<String>,
}
fn judge_stage(stage: &str, lifted: &Lifted, ctx: StageCtx) -> Result<Emitted, Refusal> {
let mut emitted = Emitted::default();
if stage.is_empty() {
return Ok(emitted);
}
let arrays = array_bindings(stage, lifted)?;
let header = function_header_words(stage);
let raw = stage;
let stage = ungroup(stage);
let tokens = tokenize(&stage).map_err(|_| Refusal::Unreadable("its quoting"))?;
let routed = apply_redirections(&tokens, ctx.out, ctx.err, lifted)?;
if routed.target_carries {
route(routed.err, &mut emitted)?;
}
let argv = &routed.argv;
let reads_input = argv
.iter()
.any(|w| carries_kind(w, lifted, Some(SubKind::Input)));
let stdin_carries = ctx.stdin_carries || routed.here_carries || reads_input;
emitted.text = argv.iter().any(|w| input_is_program_text(w)) || routed.here_program_text;
let (keywords, coproc) = keyword_words(argv, header);
let kw = header + keywords;
let resolved = resolve_program_word(argv.get(kw..).unwrap_or_default());
let start = kw + resolved.map_or(0, |w| w.index);
let program_word = argv.get(start).map(String::as_str).unwrap_or_default();
let program = basename(program_word);
let args = argv.get(start + 1..).unwrap_or_default();
let consumer = consumes_stdin(&program, args);
emitted.text |= ctx.stdin_text && !consumer;
if enables_xtrace(&program, argv, args) {
return Err(Refusal::Prints);
}
emitted.bound = bound_names(argv, kw, &program, args, lifted);
emitted.bound.extend(arrays);
if reads_in_arithmetic(raw, argv, &program, &lifted.names) {
return Err(Refusal::Prints);
}
if coproc && (!lifted.names.is_empty() || argv.iter().any(|w| carries(w, lifted))) {
return Err(Refusal::Prints);
}
if dumps_variables(argv, &program, args, &lifted.names) {
route(routed.out, &mut emitted)?;
}
let sinks = (routed.out, routed.err);
judge_builtin_sinks(&program, args, sinks, lifted, ctx.depth, &mut emitted)?;
if program == "select" && select_items_carry(argv, lifted) {
route(routed.err, &mut emitted)?;
}
if reports_unset_error(argv, &lifted.names) {
route(routed.err, &mut emitted)?;
}
if program_word.is_empty() {
return Ok(emitted);
}
if carries(program_word, lifted) {
return Err(Refusal::Prints);
}
let call = first_credential_program(argv);
let dynamic_before_trigger = call.is_none()
&& argv.iter().position(|w| has_trigger(w)).is_some_and(|t| {
argv.get(..t)
.unwrap_or_default()
.iter()
.any(|w| w.starts_with('$'))
});
if program_word.starts_with('$') || program_word.starts_with(MARK) || dynamic_before_trigger {
return Err(Refusal::Unreadable("a program name chosen at run time"));
}
if interactive_security(argv, start) {
return Err(Refusal::Unreadable(
"the commands `security -i` reads from its input",
));
}
let wrapped = if program == "eval" {
WrappedCommand::None
} else {
wrapped_command(&stage)
};
let evaluator_at = if resolved.is_ok() {
is_evaluator(&program).then_some(start)
} else {
argv.iter()
.skip(start)
.position(|w| is_evaluator(&basename(w)))
.map(|p| p + start)
};
if let Some(at) = evaluator_at.filter(|&at| at != start || wrapped == WrappedCommand::None) {
let word = basename(&argv[at]);
let evaluator = evaluator_name(&word).unwrap_or(&word);
let operands = argv.get(at + 1..).unwrap_or_default();
let code = code_operands(evaluator, operands);
let tainted = !lifted.names.is_empty();
let reads_stdin = operands.iter().all(|a| a.starts_with('-'));
let fed = matches!(evaluator, "eval" | "source" | ".")
|| code.iter().any(|c| input_is_program_text(c))
|| (tainted && (!code.is_empty() || routed.here_any))
|| (tainted && reads_stdin && ctx.stdin_piped)
|| (tainted && reads_script_by_path(operands, lifted))
|| routed.here_program_text
|| ctx.stdin_text;
if fed {
return Err(Refusal::Unreadable("text handed to a program that runs it"));
}
}
let carried = stdin_carries
|| call.is_some()
|| argv.iter().any(|w| carries(w, lifted) || has_trigger(w));
if resolved.is_err() && wrapped == WrappedCommand::None && carried {
return Err(Refusal::Unreadable("the program behind a wrapper option"));
}
let (out, err) = (routed.out, routed.err);
let xargs_args = match resolved.ok().and_then(|w| w.xargs_at) {
Some(x) => argv.get(kw + x + 1..),
None => (program == "xargs").then_some(args),
};
match wrapped {
WrappedCommand::Unlexable => return Err(Refusal::Unreadable("its wrapped command")),
WrappedCommand::Inner(inner) => {
let err_carries = matches!(err, Sink::Pipe | Sink::Captured);
let inner_err = match err {
Sink::Terminal | Sink::Unknown => err,
Sink::Pipe | Sink::Captured => Sink::Captured,
Sink::Discarded => Sink::Discarded,
};
let prints = if let Some(xargs_args) = xargs_args.filter(|_| stdin_carries) {
let mut with_value = lifted.clone();
let mark = format!("{MARK}{}__", with_value.subs.len());
with_value.subs.push(Sub {
kind: SubKind::Command,
yields: true,
});
let text = inject_xargs_value(&inner, xargs_args, &mark);
scan(&text, Sink::Captured, inner_err, ctx.depth + 1, &with_value)?
} else {
scan(&inner, Sink::Captured, inner_err, ctx.depth + 1, lifted)? || stdin_carries
};
if prints {
route(out, &mut emitted)?;
if err_carries {
route(err, &mut emitted)?;
}
}
return Ok(emitted);
}
WrappedCommand::None => {}
}
let (mut fd1, fd2) = call.map_or((false, false), |at| {
credential_fds(&basename(&argv[at]), argv.get(at + 1..).unwrap_or_default())
});
if ARG_PRINTERS.contains(&program.as_str()) && args.iter().any(|a| carries(a, lifted)) {
fd1 = true;
if reports_operand_on_stderr(&program, args, lifted) {
route(err, &mut emitted)?;
}
if program == "print" {
route_print_unit(args, &routed.fds, &mut emitted)?;
}
}
if stdin_carries && !consumer {
fd1 = true;
for (n, a) in args.iter().enumerate() {
let path = a.strip_prefix("of=").unwrap_or(a);
let writes = match program.as_str() {
"tee" => !a.starts_with('-') || args[..n].iter().any(|b| b == "--"),
"dd" => a.starts_with("of="),
_ => false,
};
let sink = if writes {
Some(redirect_target_sink(path, &routed.fds, lifted, ctx.out))
} else {
terminal_name_sink(path, &routed.fds, lifted, ctx.out)
};
if let Some(sink) = sink {
route(sink, &mut emitted)?;
}
}
}
if fd1 {
route(out, &mut emitted)?;
}
if fd2 {
route(err, &mut emitted)?;
}
judge_cli_echoes(argv, &routed, lifted, ctx.out, &mut emitted)?;
Ok(emitted)
}
fn inject_xargs_value(inner: &str, xargs_args: &[String], mark: &str) -> String {
let mut replace = None;
let mut n = 0;
while let Some(a) = xargs_args.get(n) {
n += 1;
match a.as_str() {
"-I" => {
replace = xargs_args.get(n).cloned();
n += 1;
}
"-i" | "--replace" => replace = Some("{}".to_string()),
_ if !a.starts_with('-') => break,
_ => {
if let Some(v) = a
.strip_prefix("--replace=")
.or_else(|| a.strip_prefix("-I"))
{
replace = Some(v.to_string());
} else if let Some(v) = a.strip_prefix("-i") {
replace = Some(v.to_string());
}
}
}
}
match replace.filter(|r| !r.is_empty() && inner.contains(r.as_str())) {
Some(r) => inner.replace(r.as_str(), mark),
None => format!("{inner} {mark}"),
}
}
fn route(sink: Sink, emitted: &mut Emitted) -> Result<(), Refusal> {
match sink {
Sink::Terminal => return Err(Refusal::Prints),
Sink::Captured => emitted.captured = true,
Sink::Pipe => emitted.piped = true,
Sink::Discarded => {}
Sink::Unknown => return Err(Refusal::Unreadable("an output target chosen at run time")),
}
Ok(())
}
fn carries(word: &str, lifted: &Lifted) -> bool {
carries_kind(word, lifted, None) || expands_tainted(word, &lifted.names)
}
fn carries_kind(word: &str, lifted: &Lifted, kind: Option<SubKind>) -> bool {
marks_in(word).any(|i| {
lifted
.subs
.get(i)
.is_some_and(|s| s.yields && kind.is_none_or(|k| s.kind == k))
})
}
fn select_items_carry(argv: &[String], lifted: &Lifted) -> bool {
argv.iter().position(|w| w == "in").is_some_and(|at| {
argv.get(at + 1..)
.unwrap_or_default()
.iter()
.any(|w| carries(w, lifted))
})
}
fn marks_in(word: &str) -> impl Iterator<Item = usize> + '_ {
word.match_indices(MARK).filter_map(|(at, _)| {
let digits: String = word[at + MARK.len()..]
.chars()
.take_while(char::is_ascii_digit)
.collect();
digits.parse().ok()
})
}
#[cfg(test)]
#[path = "credential_print_tests.rs"]
mod credential_print_tests;