use std::collections::{HashMap, HashSet};
const GUARD_BLOCK_CAP: usize = 60;
const FUNCTION_BODY_CAP: usize = 200;
const SAFE_ENV_VARS: &[&str] = &[
"PWD",
"OLDPWD",
"HOME",
"TMPDIR",
"PATH",
"SHELL",
"TERM",
"LANG",
"LC_ALL",
"USER",
"LOGNAME",
"HOSTNAME",
"UID",
"EUID",
"PPID",
"SHLVL",
"LINENO",
"RANDOM",
"SECONDS",
"IFS",
"BASH_SOURCE",
"BASH_VERSION",
"FUNCNAME",
"PS1",
];
const SHELL_META: &[&str] = &["#", "?", "$", "!", "-"];
const EXTERNAL_SPECIALS: &[&str] = &["OPTARG", "REPLY"];
const SPECIAL_CHARS: &[u8] = b"@*#?$!-";
const EXTERNAL_DATA_CMDS: &[&str] = &[
"curl", "wget", "nc", "ncat", "ssh", "scp", "git", "aws", "gcloud", "kubectl", "jq",
];
const READ_COMMANDS: &[&str] = &["read", "readarray", "mapfile"];
const READ_OPTS_WITH_ARG: &[&str] = &["-p", "-d", "-n", "-N", "-t", "-u", "-i"];
const ASSIGN_DELIMS: &[u8] = b" \t;&|(){}";
const STATEMENT_SEPARATORS: &[char] = &[';', '&', '|', '\n', '(', ')', '{', '}'];
const STATEMENT_KEYWORDS: &[&str] = &["if", "then", "do", "while", "until", "time", "!"];
const BLOCK_OPENERS: &[&str] = &["if", "case", "while", "until", "for"];
const BLOCK_CLOSERS: &[&str] = &["fi", "esac", "done"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum TaintKind {
#[default]
Clean,
Ambient,
External,
}
#[derive(Debug, Clone, Default)]
pub struct TaintMap {
changes: HashMap<String, Vec<(usize, TaintKind)>>,
in_function: HashSet<usize>,
}
impl TaintMap {
pub fn var_taint(&self, line_idx: usize, var: &str) -> TaintKind {
match self.recorded(line_idx, var) {
Some(kind) => kind,
None => contextual_intrinsic(var, self.in_function.contains(&line_idx)),
}
}
pub fn line_taint(&self, line_idx: usize, line: &str) -> TaintKind {
var_names(line)
.into_iter()
.map(|name| self.var_taint(line_idx, &name))
.max()
.unwrap_or_default()
}
pub fn path_taint(&self, line_idx: usize, args: &str) -> TaintKind {
args.split('/')
.filter(|part| part.contains('$'))
.flat_map(var_names)
.map(|name| self.var_taint(line_idx, &name))
.max()
.unwrap_or_default()
}
fn recorded(&self, line_idx: usize, var: &str) -> Option<TaintKind> {
let history = self.changes.get(var)?;
let pos = history.partition_point(|(from, _)| *from <= line_idx);
history.get(pos.checked_sub(1)?).map(|(_, kind)| *kind)
}
}
#[derive(Debug, Default)]
struct Ctx {
current: HashMap<String, TaintKind>,
changes: HashMap<String, Vec<(usize, TaintKind)>>,
blocks: HashMap<String, Option<usize>>,
in_function: bool,
}
impl Ctx {
fn set(&mut self, name: String, kind: TaintKind, from: usize) {
if self.current.get(&name) == Some(&kind) {
return;
}
self.current.insert(name.clone(), kind);
self.changes.entry(name).or_default().push((from, kind));
}
fn get(&self, name: &str) -> TaintKind {
match self.current.get(name) {
Some(kind) => *kind,
None => contextual_intrinsic(name, self.in_function),
}
}
}
pub fn analyze(source: &str) -> TaintMap {
let lines: Vec<&str> = source.lines().collect();
let heredoc_body = crate::linter::heredoc::quoted_heredoc_lines(source);
let in_function = function_body_lines(&lines);
let validators = collect_validator_functions(&lines);
let untaints = guard_untaints(&lines, &in_function);
let blocks = block_ids(&lines, &heredoc_body);
let mut ctx = Ctx::default();
for (idx, line) in lines.iter().enumerate() {
if heredoc_body.contains(&(idx + 1)) || line.trim_start().starts_with('#') {
continue;
}
ctx.in_function = in_function.contains(&idx);
apply_line(&mut ctx, line, idx + 1, blocks.get(idx).copied().flatten());
apply_validator_call(&mut ctx, line, &validators, idx + 1);
apply_untaints(&mut ctx, untaints.get(&idx), idx + 1);
}
TaintMap {
changes: ctx.changes,
in_function,
}
}
fn apply_validator_call(
ctx: &mut Ctx,
line: &str,
validators: &HashSet<String>,
from: usize,
) -> bool {
match validator_call_var(line, validators) {
Some(var) => {
ctx.set(var, TaintKind::Clean, from);
true
}
None => false,
}
}
fn apply_untaints(ctx: &mut Ctx, vars: Option<&Vec<String>>, from: usize) {
for var in vars.into_iter().flatten() {
ctx.set(var.clone(), TaintKind::Clean, from);
}
}
fn contextual_intrinsic(name: &str, in_function: bool) -> TaintKind {
let kind = intrinsic_taint(name);
if in_function && kind == TaintKind::External && positional_taint(name).is_some() {
return TaintKind::Ambient;
}
kind
}
fn intrinsic_taint(name: &str) -> TaintKind {
if let Some(kind) = positional_taint(name) {
return kind;
}
if SHELL_META.contains(&name) {
return TaintKind::Clean;
}
if EXTERNAL_SPECIALS.contains(&name) || name.starts_with('!') {
return TaintKind::External;
}
if is_safe_env_var(name) {
return TaintKind::Clean;
}
TaintKind::Ambient
}
fn positional_taint(name: &str) -> Option<TaintKind> {
if name == "@" || name == "*" {
return Some(TaintKind::External);
}
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
if name == "0" {
return Some(TaintKind::Clean);
}
Some(TaintKind::External)
}
fn is_safe_env_var(name: &str) -> bool {
name.starts_with("XDG_") || SAFE_ENV_VARS.contains(&name)
}
fn var_names(text: &str) -> Vec<String> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'$' {
i += 1;
continue;
}
match read_var_at(bytes, i) {
Some((name, next)) => {
out.push(name);
i = next;
}
None => i += 1,
}
}
out
}
fn read_var_at(bytes: &[u8], i: usize) -> Option<(String, usize)> {
let start = i + 1;
match bytes.get(start) {
None => None,
Some(b'{') => read_braced(bytes, start + 1),
Some(b'(') => None,
Some(_) => read_plain(bytes, start),
}
}
fn read_plain(bytes: &[u8], start: usize) -> Option<(String, usize)> {
let first = *bytes.get(start)?;
if first.is_ascii_digit() {
let end = scan_while(bytes, start, |b| b.is_ascii_digit());
return Some((slice_string(bytes, start, end), end));
}
if first.is_ascii_alphabetic() || first == b'_' {
let end = scan_while(bytes, start, is_name_byte);
return Some((slice_string(bytes, start, end), end));
}
if SPECIAL_CHARS.contains(&first) {
return Some((slice_string(bytes, start, start + 1), start + 1));
}
None
}
fn read_braced(bytes: &[u8], start: usize) -> Option<(String, usize)> {
let close = scan_while(bytes, start, |b| b != b'}');
if close >= bytes.len() {
return None;
}
let name = brace_name(&slice_string(bytes, start, close))?;
Some((name, close + 1))
}
fn brace_name(inner: &str) -> Option<String> {
let indirect = inner.starts_with('!');
let body = inner.trim_start_matches(['!', '#']);
let name: String = body
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
if name.is_empty() {
return None;
}
if indirect {
return Some(format!("!{name}"));
}
Some(name)
}
fn scan_while(bytes: &[u8], start: usize, pred: impl Fn(u8) -> bool) -> usize {
let mut end = start;
while end < bytes.len() && pred(bytes[end]) {
end += 1;
}
end
}
fn slice_string(bytes: &[u8], start: usize, end: usize) -> String {
String::from_utf8_lossy(&bytes[start..end]).into_owned()
}
fn is_name_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn is_valid_name(word: &str) -> bool {
let mut chars = word.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn code_only(text: &str) -> String {
scan_text(text, true)
}
fn bare_code(text: &str) -> String {
scan_text(text, false)
}
fn scan_text(text: &str, blank_literals: bool) -> String {
text.split('\n')
.map(|line| scan_line(line, blank_literals))
.collect::<Vec<_>>()
.join("\n")
}
fn scan_line(line: &str, blank_literals: bool) -> String {
let mut out = String::with_capacity(line.len());
let mut quote: Option<char> = None;
for c in line.chars() {
match quote {
Some(q) => quote = scan_in_quote(&mut out, c, q, blank_literals),
None => {
if !scan_in_code(&mut out, c, &mut quote, blank_literals) {
break;
}
}
}
}
out
}
fn scan_in_quote(out: &mut String, c: char, quote: char, blank: bool) -> Option<char> {
if c == quote {
out.push(if blank { '\u{1}' } else { c });
return None;
}
if !blank {
out.push(c);
}
Some(quote)
}
fn scan_in_code(out: &mut String, c: char, quote: &mut Option<char>, blank: bool) -> bool {
if c == '#' && starts_word(out) {
return false;
}
if c == '"' || c == '\'' {
*quote = Some(c);
if !blank {
out.push(c);
}
return true;
}
out.push(c);
true
}
fn starts_word(out: &str) -> bool {
match out.chars().last() {
None => true,
Some(c) => c.is_whitespace(),
}
}
fn statements(code: &str) -> std::str::Split<'_, &'static [char]> {
code.split(STATEMENT_SEPARATORS)
}
fn command_word(stmt: &str) -> Option<(&str, Option<&str>)> {
let mut words = stmt
.split_whitespace()
.skip_while(|w| STATEMENT_KEYWORDS.contains(w));
Some((words.next()?, words.next()))
}
fn count_keywords(code: &str, words: &[&str]) -> usize {
statements(code)
.filter_map(|stmt| stmt.split_whitespace().next())
.filter(|word| words.contains(word))
.count()
}
fn apply_line(ctx: &mut Ctx, line: &str, from: usize, block: Option<usize>) {
if apply_read(ctx, line, from) {
return;
}
if apply_for_loop(ctx, line, from) {
return;
}
apply_assignment(ctx, line, from, block);
}
fn apply_assignment(ctx: &mut Ctx, line: &str, from: usize, block: Option<usize>) -> bool {
let Some((name, rhs)) = split_assignment(line) else {
return false;
};
let fresh = if is_sanitizer_rhs(rhs) {
TaintKind::Clean
} else {
rhs_taint(ctx, rhs)
};
let kind = if block.is_some() || assignment_is_guarded(line, &name) {
merged_kind(ctx, &name, fresh, block)
} else {
fresh
};
ctx.blocks.insert(name.clone(), block);
ctx.set(name, kind, from);
true
}
fn merged_kind(ctx: &Ctx, name: &str, fresh: TaintKind, block: Option<usize>) -> TaintKind {
match ctx.current.get(name) {
Some(prior) if ctx.blocks.get(name) == Some(&block) => fresh.max(*prior),
_ => fresh,
}
}
fn assignment_is_guarded(line: &str, name: &str) -> bool {
let code = code_only(line);
let head = match word_pos(&code, name) {
Some(pos) => code.get(..pos).unwrap_or(""),
None => code.as_str(),
};
head.contains("&&") || head.contains("||")
}
fn block_ids(lines: &[&str], heredoc_body: &HashSet<usize>) -> Vec<Option<usize>> {
let mut out = Vec::with_capacity(lines.len());
let mut open: Option<usize> = None;
let mut depth: usize = 0;
for (idx, line) in lines.iter().enumerate() {
if heredoc_body.contains(&(idx + 1)) {
out.push(open);
continue;
}
let code = code_only(line);
let opens = count_keywords(&code, BLOCK_OPENERS);
if open.is_none() && opens > 0 {
open = Some(idx);
}
out.push(open);
depth = (depth + opens).saturating_sub(count_keywords(&code, BLOCK_CLOSERS));
if depth == 0 {
open = None;
}
}
out
}
fn split_assignment(line: &str) -> Option<(String, &str)> {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'=' {
i += 1;
continue;
}
if bytes.get(i + 1) == Some(&b'=') {
i += 2;
continue;
}
if let Some(name) = name_before(bytes, i) {
return Some((name, line.get(i + 1..)?));
}
i += 1;
}
None
}
fn name_before(bytes: &[u8], eq: usize) -> Option<String> {
let mut start = eq;
while start > 0 && is_name_byte(bytes[start - 1]) {
start -= 1;
}
if start == eq || bytes[start].is_ascii_digit() {
return None;
}
if start > 0 && !ASSIGN_DELIMS.contains(&bytes[start - 1]) {
return None;
}
Some(slice_string(bytes, start, eq))
}
fn rhs_taint(ctx: &Ctx, rhs: &str) -> TaintKind {
let from_vars = var_names(rhs)
.into_iter()
.map(|name| ctx.get(&name))
.max()
.unwrap_or_default();
from_vars.max(cmd_sub_taint(rhs))
}
fn cmd_sub_taint(rhs: &str) -> TaintKind {
if !rhs.contains("$(") && !rhs.contains('`') {
return TaintKind::Clean;
}
if EXTERNAL_DATA_CMDS
.iter()
.any(|cmd| word_pos(rhs, cmd).is_some())
{
return TaintKind::External;
}
TaintKind::Clean
}
fn is_sanitizer_rhs(rhs: &str) -> bool {
rhs.contains("realpath")
|| rhs.contains("readlink -f")
|| rhs.contains("readlink --canonicalize")
}
fn apply_read(ctx: &mut Ctx, line: &str, from: usize) -> bool {
let Some(args) = read_command_args(line) else {
return false;
};
let masked = mask_quoted(args);
let head = masked.split([';', '<', '|', '&']).next().unwrap_or("");
let mut assigned = false;
let mut skip_next = false;
for token in head.split_whitespace() {
if skip_next {
skip_next = false;
continue;
}
if token.starts_with('-') {
skip_next = READ_OPTS_WITH_ARG.contains(&token);
continue;
}
if !is_valid_name(token) {
break;
}
ctx.set(token.to_string(), TaintKind::External, from);
assigned = true;
}
assigned
}
fn mask_quoted(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut quote: Option<char> = None;
for c in text.chars() {
match quote {
Some(q) if c == q => {
quote = None;
out.push('\u{1}');
}
Some(_) => {}
None if c == '"' || c == '\'' => quote = Some(c),
None => out.push(c),
}
}
out
}
fn read_command_args(line: &str) -> Option<&str> {
for cmd in READ_COMMANDS {
if let Some(pos) = word_pos(line, cmd) {
return line.get(pos + cmd.len()..);
}
}
None
}
fn apply_for_loop(ctx: &mut Ctx, line: &str, from: usize) -> bool {
let Some((name, words)) = split_for(line) else {
return false;
};
let kind = rhs_taint(ctx, words);
ctx.set(name, kind, from);
true
}
fn split_for(line: &str) -> Option<(String, &str)> {
let rest = line.trim().strip_prefix("for ")?;
let mut parts = rest.splitn(2, " in ");
let name = parts.next()?.trim();
let words = parts.next()?;
if !is_valid_name(name) {
return None;
}
Some((name.to_string(), words))
}
fn guard_untaints(lines: &[&str], in_function: &HashSet<usize>) -> HashMap<usize, Vec<String>> {
let mut out: HashMap<usize, Vec<String>> = HashMap::new();
for idx in 0..lines.len() {
if in_function.contains(&idx) {
continue;
}
if let Some(var) = inline_guard(lines[idx]) {
out.entry(idx).or_default().push(var);
continue;
}
if let Some((end, var)) = block_guard(lines, idx) {
out.entry(end).or_default().push(var);
}
}
out
}
fn inline_guard(line: &str) -> Option<String> {
let subject = var_names(line).into_iter().next()?;
if is_inline_case(line) {
return case_rejects_traversal(line).then_some(subject);
}
if !line_tests_traversal(line) || !line_hard_fails(line) {
return None;
}
Some(subject)
}
fn is_inline_case(line: &str) -> bool {
let code = bare_code(line);
code.contains("case ") && code.contains("esac")
}
fn block_guard(lines: &[&str], idx: usize) -> Option<(usize, String)> {
let trimmed = lines.get(idx)?.trim();
if trimmed.starts_with("case ") && trimmed.ends_with(" in") {
return guard_block(lines, idx, "esac", case_rejects_traversal);
}
if is_if_opener(trimmed) && line_tests_traversal(trimmed) {
return guard_block(lines, idx, "fi", then_branch_rejects);
}
None
}
fn is_if_opener(trimmed: &str) -> bool {
trimmed.starts_with("if ") || trimmed.starts_with("elif ")
}
fn guard_block(
lines: &[&str],
idx: usize,
closer: &str,
rejects: impl Fn(&str) -> bool,
) -> Option<(usize, String)> {
let subject = var_names(lines.get(idx)?).into_iter().next()?;
let end = find_closer(lines, idx, closer)?;
let body = lines.get(idx..=end)?.join("\n");
rejects(&body).then_some((end, subject))
}
fn case_rejects_traversal(text: &str) -> bool {
bare_code(text).split(";;").any(arm_rejects_traversal)
}
fn arm_rejects_traversal(arm: &str) -> bool {
line_is_traversal_case_arm(arm) && text_hard_fails(&code_only(arm))
}
fn then_branch_rejects(text: &str) -> bool {
for stmt in statements(&code_only(text)) {
if opens_other_branch(stmt) {
return false;
}
if statement_hard_fails(stmt) {
return true;
}
}
false
}
fn opens_other_branch(stmt: &str) -> bool {
matches!(stmt.split_whitespace().next(), Some("else") | Some("elif"))
}
fn find_closer(lines: &[&str], start: usize, closer: &str) -> Option<usize> {
let last = lines.len().checked_sub(1)?;
let cap = (start + GUARD_BLOCK_CAP).min(last);
(start..=cap).find(|idx| lines.get(*idx).is_some_and(|l| line_closes(l, closer)))
}
fn line_closes(line: &str, closer: &str) -> bool {
let trimmed = line.trim();
trimmed == closer
|| trimmed.starts_with(&format!("{closer} "))
|| trimmed.ends_with(&format!(" {closer}"))
|| trimmed.ends_with(&format!(";{closer}"))
}
fn line_has_traversal_pattern(line: &str) -> bool {
line.contains("..")
|| line.contains("\\.\\.")
|| line.contains("/*")
|| line.contains("realpath")
|| line.contains("readlink")
}
fn line_has_test(line: &str) -> bool {
line.contains("==")
|| line.contains("!=")
|| line.contains("=~")
|| line.contains("case ")
|| line.contains("grep")
}
fn line_tests_traversal(line: &str) -> bool {
line_has_traversal_pattern(line) && line_has_test(line)
}
fn line_is_traversal_case_arm(line: &str) -> bool {
match line.split_once(')') {
Some((pattern, _)) => line_has_traversal_pattern(pattern),
None => false,
}
}
fn line_hard_fails(line: &str) -> bool {
text_hard_fails(&code_only(line))
}
fn text_hard_fails(code: &str) -> bool {
statements(code).any(statement_hard_fails)
}
fn statement_hard_fails(stmt: &str) -> bool {
match command_word(stmt) {
Some((cmd, arg)) => command_hard_fails(cmd, arg),
None => false,
}
}
fn command_hard_fails(cmd: &str, arg: Option<&str>) -> bool {
match cmd {
"exit" => arg != Some("0"),
"return" => arg.is_some_and(|status| status != "0"),
"continue" | "die" | "fatal" | "abort" => true,
_ => false,
}
}
fn collect_validator_functions(lines: &[&str]) -> HashSet<String> {
let mut out = HashSet::new();
let mut idx = 0;
while idx < lines.len() {
let Some(name) = lines.get(idx).and_then(|l| function_header(l)) else {
idx += 1;
continue;
};
let end = function_end(lines, idx);
if lines.get(idx..=end).is_some_and(body_is_path_validator) {
out.insert(name);
}
idx = end + 1;
}
out
}
fn body_is_path_validator(body: &[&str]) -> bool {
(0..body.len()).any(|idx| body_line_guards(body, idx))
}
fn body_line_guards(body: &[&str], idx: usize) -> bool {
match body.get(idx) {
Some(line) => inline_guard(line).is_some() || block_guard(body, idx).is_some(),
None => false,
}
}
fn function_body_lines(lines: &[&str]) -> HashSet<usize> {
let mut out = HashSet::new();
let mut idx = 0;
while idx < lines.len() {
if lines.get(idx).and_then(|l| function_header(l)).is_none() {
idx += 1;
continue;
}
let end = function_end(lines, idx);
out.extend(idx..=end);
idx = end + 1;
}
out
}
fn function_header(line: &str) -> Option<String> {
let trimmed = line.trim();
let trimmed = match trimmed.strip_prefix("function ") {
Some(rest) => rest.trim_start(),
None => trimmed,
};
let paren = trimmed.find("()")?;
let name = trimmed.get(..paren)?.trim();
if !is_valid_name(name) {
return None;
}
Some(name.to_string())
}
fn function_end(lines: &[&str], start: usize) -> usize {
let last = lines.len().saturating_sub(1);
let cap = (start + FUNCTION_BODY_CAP).min(last);
let mut depth: i32 = 0;
for idx in start..=cap {
depth += brace_delta(lines.get(idx).copied().unwrap_or(""));
if depth > 0 {
continue;
}
if idx > start || lines.get(idx).is_some_and(|l| l.contains('}')) {
return idx;
}
}
cap
}
fn brace_delta(line: &str) -> i32 {
let opens = line.matches('{').count() as i32;
let closes = line.matches('}').count() as i32;
opens - closes
}
fn validator_call_var(line: &str, validators: &HashSet<String>) -> Option<String> {
if validators.is_empty() || function_header(line).is_some() {
return None;
}
let trimmed = line.trim();
let head = trimmed.split_whitespace().next()?;
if !validators.contains(head) {
return None;
}
var_names(trimmed).into_iter().next()
}
fn word_pos(line: &str, word: &str) -> Option<usize> {
let mut from = 0;
while let Some(rel) = line.get(from..)?.find(word) {
let pos = from + rel;
if word_boundaries_ok(line.as_bytes(), pos, word.len()) {
return Some(pos);
}
from = pos + 1;
}
None
}
fn word_boundaries_ok(bytes: &[u8], pos: usize, len: usize) -> bool {
let before_ok = pos == 0 || !is_name_byte(bytes[pos - 1]);
let after_ok = match bytes.get(pos + len) {
None => true,
Some(b) => !is_name_byte(*b),
};
before_ok && after_ok
}
#[cfg(test)]
#[path = "taint_tests.rs"]
mod tests;