use std::collections::BTreeSet;
use super::credential_print_programs::basename;
use super::credential_print_taint_forms::reads_tainted;
use super::{Lifted, carries};
use crate::commands::hook_rewrite::is_env_assignment;
pub(super) const POSITIONAL: &str = "@";
pub(super) const ANY: &str = "$any";
pub(super) const INTEGER: &str = "$integer";
pub(super) const DECLARERS: &[&str] = &["declare", "typeset", "export", "readonly", "local"];
const REMATCH: &[&str] = &["BASH_REMATCH", "match", "MATCH"];
pub(super) fn bound_names(
argv: &[String],
kw: usize,
program: &str,
args: &[String],
lifted: &Lifted,
) -> Vec<String> {
let mut bound = Vec::new();
if declares_integer(program, args) && !lifted.names.is_empty() {
bound.push(INTEGER.to_string());
}
for word in argv {
let Some((lhs, value)) = word.split_once('=') else {
continue;
};
let lhs = lhs.strip_suffix('+').unwrap_or(lhs);
let name = lhs.split('[').next().unwrap_or(lhs);
let carried = carries(value, lifted) || reads_tainted(value, &lifted.names);
if !carried {
continue;
}
if is_identifier(name) {
bound.push(name.to_string());
} else {
bound.push(ANY.to_string());
}
}
let rest = argv.get(kw..).unwrap_or_default();
if let [head, name, list @ ..] = rest
&& matches!(head.as_str(), "for" | "select")
{
let carried = match list.split_first() {
Some((first, words)) if first == "in" => words
.iter()
.any(|w| carries(w, lifted) || reads_tainted(w, &lifted.names)),
_ => is_tainted(POSITIONAL, &lifted.names),
};
if carried {
bound.push(name.clone());
}
}
if program == "set" && args.iter().any(|a| carries(a, lifted)) {
bound.push(POSITIONAL.to_string());
}
if argv.iter().any(|w| w == "=~") && argv.iter().any(|w| carries(w, lifted)) {
bound.extend(REMATCH.iter().map(|n| (*n).to_string()));
}
bound
}
pub(super) fn is_tainted(name: &str, names: &BTreeSet<String>) -> bool {
let positional = matches!(name, "@" | "*")
|| (!name.is_empty() && name != "0" && name.bytes().all(|b| b.is_ascii_digit()));
names.contains(name)
|| (positional && names.contains(POSITIONAL))
|| (names.contains(ANY) && (positional || is_identifier(name)))
}
pub(super) fn declares_integer(program: &str, args: &[String]) -> bool {
program == "integer"
|| (DECLARERS.contains(&program)
&& args
.iter()
.any(|a| a.starts_with('-') && !a.starts_with("--") && a.contains('i')))
}
const MAX_BRACE_DEPTH: usize = 32;
pub(super) fn expands_tainted(word: &str, names: &BTreeSet<String>) -> bool {
!names.is_empty() && expands_at(word, names, 0)
}
fn expands_at(word: &str, names: &BTreeSet<String>, depth: usize) -> bool {
if depth > MAX_BRACE_DEPTH {
return true;
}
let bytes = word.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'$' {
i += 1;
continue;
}
i += 1;
if bytes.get(i) == Some(&b'{') {
let start = i + 1;
let mut level = 1usize;
let mut j = start;
while j < bytes.len() && level > 0 {
match bytes[j] {
b'{' => level += 1,
b'}' => level -= 1,
_ => {}
}
j += 1;
}
let end = if level == 0 { j - 1 } else { j };
if braced_carries(&word[start..end], names, depth + 1) {
return true;
}
i = j;
continue;
}
let flags = bytes[i..].iter().take_while(|b| b"=~^".contains(b)).count();
let name = leading_name(&word[i + flags..]);
if is_tainted(name, names) {
return true;
}
i += flags + name.len();
}
false
}
fn braced_carries(body: &str, names: &BTreeSet<String>, depth: usize) -> bool {
if body.starts_with('!') {
return true;
}
let mut body = body;
loop {
if let Some(after) = body.strip_prefix('(') {
let Some(close) = after.find(')') else {
return true;
};
if after[..close].contains('P') {
return true;
}
body = &after[close + 1..];
} else if let Some(after) = body.strip_prefix(['^', '=', '~']) {
body = after;
} else {
break;
}
}
if let Some(rest) = body.strip_prefix('#') {
let name = leading_name(rest);
let after = &rest[name.len()..];
let length = after.is_empty() || (after.starts_with('[') && after.ends_with(']'));
return !length && is_tainted(name, names);
}
if body.starts_with("${") {
return expands_at(body, names, depth);
}
let name = leading_name(body);
name.is_empty() || is_tainted(name, names) || expands_at(&body[name.len()..], names, depth)
}
pub(crate) fn is_identifier(text: &str) -> bool {
text.as_bytes()
.first()
.is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_')
&& leading_name(text).len() == text.len()
}
pub(super) fn leading_name(text: &str) -> &str {
let bytes = text.as_bytes();
match bytes.first() {
Some(b) if b.is_ascii_alphabetic() || *b == b'_' => {
let len = bytes
.iter()
.take_while(|b| b.is_ascii_alphanumeric() || **b == b'_')
.count();
&text[..len]
}
Some(b) if b.is_ascii_digit() || b"@*#?-$!".contains(b) => &text[..1],
_ => "",
}
}
pub(super) fn dumps_variables(
argv: &[String],
program: &str,
args: &[String],
names: &BTreeSet<String>,
) -> bool {
if names.is_empty() {
return false;
}
let operands = |words: &[String]| -> Vec<String> {
words
.iter()
.filter(|w| !w.starts_with('-'))
.map(|w| w.split('=').next().unwrap_or_default().to_string())
.collect()
};
for (at, word) in argv.iter().enumerate() {
let rest = argv.get(at + 1..).unwrap_or_default();
match basename(word).as_str() {
"printenv" => {
let ops = operands(rest);
if ops.is_empty() || ops.iter().any(|o| is_tainted(o, names)) {
return true;
}
}
"env" if env_runs_nothing(rest) => return true,
_ => {}
}
}
if reads_environment(argv, program, args) {
return true;
}
match program {
"set" => args.is_empty(),
p if DECLARERS.contains(&p) => {
let ops = operands(args);
let print = args
.iter()
.any(|a| a.starts_with('-') && !a.starts_with("--") && a.contains('p'));
let shows = matches!(p, "typeset" | "local" | "declare")
&& !args.iter().any(|a| a.starts_with(['-', '+']))
&& args
.iter()
.any(|a| !a.contains('=') && is_tainted(a, names));
shows || ops.is_empty() || (print && ops.iter().any(|o| is_tainted(o, names)))
}
_ => false,
}
}
fn reads_environment(argv: &[String], program: &str, args: &[String]) -> bool {
if argv
.iter()
.any(|w| w.contains("/proc/") && w.contains("environ"))
{
return true;
}
let words = |a: &String| {
a.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$'))
.map(str::to_string)
.collect::<Vec<_>>()
};
match program {
"awk" | "gawk" | "mawk" | "nawk" => args.iter().any(|a| a.contains("ENVIRON")),
"jq" | "gojq" | "jaq" => args
.iter()
.any(|a| words(a).iter().any(|w| w == "env" || w == "$ENV")),
"ps" => args.iter().any(|a| {
let cluster = a.strip_prefix('-').unwrap_or(a);
!a.starts_with("--")
&& cluster.bytes().all(|b| b.is_ascii_alphabetic())
&& cluster.contains(['e', 'E'])
}),
_ => false,
}
}
fn env_runs_nothing(words: &[String]) -> bool {
let mut i = 0;
while let Some(w) = words.get(i) {
i += 1;
if w == "--" {
return words[i..].iter().all(|w| is_env_assignment(w));
}
if w.starts_with("-S") || w.starts_with("--split-string") {
return false;
}
if matches!(w.as_str(), "--unset" | "--chdir") {
i += 1;
} else if let Some(cluster) = w.strip_prefix('-').filter(|c| !c.starts_with('-')) {
if cluster.ends_with(['u', 'C', 'P']) {
i += 1;
}
} else if !w.starts_with('-') && !is_env_assignment(w) {
return false;
}
}
true
}