#[path = "credential_print_heredoc.rs"]
mod credential_print_heredoc;
#[path = "credential_print_programs.rs"]
mod credential_print_programs;
#[path = "credential_print_redirect.rs"]
mod credential_print_redirect;
use super::bash_tokens::tokenize;
use super::heredoc::HeredocBodies;
use super::shell_lex::{QuoteScan, WrappedCommand, wrapped_command};
use crate::commands::hook_rewrite::strip_wrapper_prefix;
use credential_print_heredoc::strip_comments_and_heredocs;
use credential_print_programs::{
KEYWORDS, basename, code_operands, consumes_stdin, credential_fds, enables_xtrace,
first_credential_program, is_evaluator,
};
use credential_print_redirect::{apply_redirections, terminal_name_sink};
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 MARK: &str = "__TMCRED";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Sink {
Terminal,
Captured,
Discarded,
Pipe,
}
#[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>,
}
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 = "Check existence by exit status alone \
(`security find-generic-password -s <service> >/dev/null 2>&1`, no `-w`/`-g`), and \
consume a value inside the command that needs it \
(`curl -H \"Authorization: Bearer $(gcloud auth print-access-token)\" …`, or a pipe \
to `--password-stdin`), never echoing it and never behind `set -x`.";
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))
}
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 lifted = outer.clone();
let text = strip_comments_and_heredocs(text, &mut lifted.heredocs);
let flat = lift_substitutions(&text, stdout, stderr, depth, &mut lifted)?;
let stages = split_stages(&flat);
let mut yields = false;
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,
depth,
};
let emitted = judge_stage(stage.trim(), &lifted, ctx)?;
yields |= emitted.captured;
stdin_carries = emitted.piped;
stdin_text = *piped && emitted.text;
}
Ok(yields)
}
#[derive(Clone, Copy)]
struct StageCtx {
out: Sink,
err: Sink,
stdin_carries: bool,
stdin_text: bool,
depth: usize,
}
#[derive(Default)]
struct Emitted {
captured: bool,
piped: bool,
text: bool,
}
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 stage = ungroup(stage);
let tokens = tokenize(&stage).map_err(|_| Refusal::Unreadable("its quoting"))?;
let routed = apply_redirections(&tokens, ctx.out, ctx.err, lifted)?;
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 kw = argv
.iter()
.take_while(|w| KEYWORDS.contains(&w.as_str()))
.count();
let resolved = argv.get(kw..).and_then(strip_wrapper_prefix);
let start = kw + resolved.unwrap_or(0);
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);
}
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"));
}
let wrapped = wrapped_command(&stage);
let evaluator_at = if resolved.is_some() {
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 evaluator = basename(&argv[at]);
let fed = matches!(evaluator.as_str(), "eval" | "source" | ".")
|| code_operands(&evaluator, argv.get(at + 1..).unwrap_or_default())
.into_iter()
.any(input_is_program_text)
|| routed.here_program_text
|| ctx.stdin_text;
if fed {
return Err(Refusal::Unreadable("text handed to a program that runs it"));
}
}
let (out, err) = (routed.out, routed.err);
match wrapped {
WrappedCommand::Unlexable => return Err(Refusal::Unreadable("its wrapped command")),
WrappedCommand::Inner(inner) => {
let inner_err = if err == Sink::Terminal {
Sink::Terminal
} else {
Sink::Discarded
};
let prints = if program == "xargs" && 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, 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)?;
}
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 stdin_carries && !consumer {
fd1 = true;
for a in args {
let path = a.strip_prefix("of=").unwrap_or(a);
if let Some(sink) = terminal_name_sink(path, &routed.fds, lifted, ctx.out) {
route(sink, &mut emitted)?;
}
}
}
if fd1 {
route(out, &mut emitted)?;
}
if fd2 {
route(err, &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 ungroup(stage: &str) -> String {
let quotes = QuoteScan::new(stage);
if !quotes.balanced {
return stage.to_string();
}
stage
.char_indices()
.map(|(i, c)| {
if matches!(c, '(' | ')') && quotes.is_unquoted(i) {
' '
} else {
c
}
})
.collect()
}
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 => {}
}
Ok(())
}
fn carries(word: &str, lifted: &Lifted) -> bool {
carries_kind(word, lifted, None)
}
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 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()
})
}
fn lift_substitutions(
text: &str,
stdout: Sink,
stderr: Sink,
depth: usize,
lifted: &mut Lifted,
) -> Result<String, Refusal> {
let scan_quotes = QuoteScan::new(text);
let live = |i: usize| !scan_quotes.balanced || scan_quotes.allows_substitution(i);
let unquoted = |i: usize| !scan_quotes.balanced || scan_quotes.is_unquoted(i);
let bytes = text.as_bytes();
let mut flat = String::with_capacity(text.len());
let (mut i, mut copied) = (0, 0);
while i < bytes.len() {
let paren = bytes.get(i + 1) == Some(&b'(')
&& match bytes[i] {
b'$' => live(i),
b'<' | b'>' => unquoted(i),
_ => false,
};
let (body, end) = if paren {
let mut level = 1usize;
let mut j = i + 2;
while j < bytes.len() && level > 0 {
match bytes[j] {
b'(' => level += 1,
b')' => level -= 1,
_ => {}
}
j += 1;
}
if level != 0 {
return Err(Refusal::Unreadable("an unclosed substitution"));
}
(&text[i + 2..j - 1], j)
} else if bytes[i] == b'`' && live(i) {
let close = text[i + 1..]
.find('`')
.ok_or(Refusal::Unreadable("an unclosed backtick"))?;
(&text[i + 1..i + 1 + close], i + close + 2)
} else {
i += 1;
continue;
};
let kind = match (paren, bytes[i]) {
(true, b'<') => SubKind::Input,
(true, b'>') => SubKind::Output,
_ => SubKind::Command,
};
let body_out = if kind == SubKind::Output {
stdout
} else {
Sink::Captured
};
let yields = scan(body, body_out, stderr, depth + 1, lifted)?;
flat.push_str(&text[copied..i]);
flat.push_str(&format!("{MARK}{}__", lifted.subs.len()));
lifted.subs.push(Sub { kind, yields });
i = end;
copied = end;
}
flat.push_str(&text[copied..]);
Ok(flat)
}
fn split_stages(text: &str) -> Vec<(String, bool, bool)> {
let quotes = QuoteScan::new(text);
let bodies = HeredocBodies::scan(text);
let bytes = text.as_bytes();
let mut stages = Vec::new();
let mut start = 0;
let mut i = 0;
while i < bytes.len() {
if (quotes.balanced && !quotes.is_unquoted(i)) || bodies.suppresses_separator(i) {
i += 1;
continue;
}
let next = bytes.get(i + 1).copied();
let (width, piped, pipe_stderr) = match (bytes[i], next) {
(b'|', Some(b'|')) | (b'&', Some(b'&')) => (2, false, false),
(b'|', Some(b'&')) => (2, true, true),
(b'|', _) => (1, true, false),
(b';' | b'\n', _) => (1, false, false),
(b'&', _) if i > 0 && matches!(bytes[i - 1], b'>' | b'<') => (0, false, false),
(b'&', Some(b'>')) => (0, false, false),
(b'&', _) => (1, false, false),
_ => (0, false, false),
};
if width == 0 {
i += 1;
continue;
}
stages.push((text[start..i].to_string(), piped, pipe_stderr));
i += width;
start = i;
}
stages.push((text[start..].to_string(), false, false));
stages
}
#[cfg(test)]
#[path = "credential_print_tests.rs"]
mod credential_print_tests;