use crate::security::detect::bash::ast::{CurrentAst, get_command_name, language};
use crate::security::detect::utils::{
node_extract_text, process_is_downloader, shell_is_unix,
};
use crate::security::detect::{Severity, ShellContext};
use std::sync::LazyLock;
use tree_sitter::{Node, Query, QueryCursor, StreamingIterator};
pub use crate::security::detect::utils::{
cluster_has_flag, command_basename, is_block_device, normalize_target, path_has_marker,
};
pub fn clean_bash_string(s: &str) -> String {
let s = s.trim();
if ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
&& s.len() >= 2
{
return s[1..s.len() - 1].to_string();
}
s.to_string()
}
pub fn extract_downloader_url(node: &Node, source: &[u8]) -> Option<String> {
let mut stack = vec![*node];
while let Some(n) = stack.pop() {
if n.kind() == "command"
&& let Some(cmd_name) = get_command_name(&n, source)
&& process_is_downloader(cmd_name)
{
let mut arg_cursor = n.walk();
for child in n.children(&mut arg_cursor) {
if (child.kind() == "word" || child.kind() == "string")
&& let Some(text) = node_extract_text(&child, source)
{
let cleaned = text.trim_matches(|c| c == '\'' || c == '"');
if cleaned.starts_with("http://") || cleaned.starts_with("https://") {
return Some(cleaned.to_string());
}
}
}
}
let mut cursor = n.walk();
for child in n.children(&mut cursor) {
stack.push(child);
}
}
None
}
pub fn detect_remote_execution(node: &Node, source: &[u8]) -> Option<String> {
match node.kind() {
"pipeline" => {
let mut cursor = node.walk();
let mut commands = Vec::new();
for child in node.children(&mut cursor) {
if child.kind() == "command" || child.kind() == "subshell" {
commands.push(child);
}
}
if commands.len() >= 2 {
let last_cmd = commands.last().unwrap();
if let Some(last_name) = get_command_name(last_cmd, source)
&& shell_is_unix(last_name)
{
for cmd in commands.iter().take(commands.len() - 1) {
if let Some(url) = extract_downloader_url(cmd, source) {
return Some(url);
}
}
}
}
}
"command" => {
if let Some(cmd_name) = get_command_name(node, source)
&& shell_is_unix(cmd_name)
{
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if (child.kind() == "process_substitution"
|| child.kind() == "command_substitution"
|| child.kind() == "string")
&& let Some(url) = extract_downloader_url(&child, source)
{
return Some(url);
}
}
}
}
_ => {}
}
None
}
static COMMON_QUERY: LazyLock<Query> = LazyLock::new(|| {
Query::new(&language(), "[(command) @cmd (file_redirect) @redirect]").expect("invalid query")
});
pub const PREFIX_COMMANDS: &[&str] = &[
"sudo", "env", "nohup", "exec", "command", "setsid", "nice", "ionice", "timeout", "stdbuf",
"time",
];
pub async fn best_hit(
ctx: &ShellContext,
analyze: impl Fn(&Node, &[u8]) -> Option<(Severity, String)>,
) -> anyhow::Result<Option<(Severity, String)>> {
let current = ctx
.extensions
.get::<CurrentAst>()
.ok_or_else(|| anyhow::anyhow!("CurrentAst missing"))?;
let blocks = current.blocks.read().await;
let query = &*COMMON_QUERY;
let mut best: Option<(Severity, String)> = None;
for block in blocks.iter() {
let source_bytes = block.source.as_bytes();
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(query, block.tree.root_node(), source_bytes);
while let Some(m) = StreamingIterator::next(&mut matches) {
for capture in m.captures {
if let Some(hit) = analyze(&capture.node, source_bytes)
&& best.as_ref().is_none_or(|(b, _)| hit.0 > *b)
{
best = Some(hit);
}
}
}
}
Ok(best)
}
pub fn collect_args<'a>(node: &Node, source: &'a [u8]) -> Vec<&'a str> {
let mut args = Vec::new();
let mut cursor = node.walk();
let mut seen_name = false;
for child in node.children(&mut cursor) {
if child.kind() == "command_name" {
seen_name = true;
continue;
}
if !seen_name {
continue;
}
if matches!(
child.kind(),
"word"
| "string"
| "raw_string"
| "translated_string"
| "ansi_c_string"
| "concatenation"
| "number"
| "simple_expansion"
| "expansion"
| "command_substitution"
| "process_substitution"
| "arithmetic_expansion"
| "brace_expression"
) && let Some(t) = node_extract_text(&child, source)
{
args.push(t);
}
}
args
}
pub fn redirect_target<'a>(node: &Node, source: &'a [u8]) -> Option<(&'a str, &'a str)> {
let mut operator: Option<&str> = None;
let mut dest: Option<&'a str> = None;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
">" | ">>" | ">|" | "<" | "<>" | "&>" | "&>>" | "<<<" => {
operator = Some(child.kind());
}
"word" if operator.is_some() => {
dest = node_extract_text(&child, source);
}
_ => {}
}
}
match (operator, dest) {
(Some(op), Some(dest)) => Some((op, dest)),
_ => None,
}
}
pub fn unwrap_command<'a>(
cmd_name: &'a str,
args: &'a [&'a str],
known: &[&str],
) -> Option<(&'a str, &'a [&'a str])> {
unwrap_command_where(cmd_name, args, |n| known.contains(&n))
}
pub fn unwrap_command_where<'a>(
cmd_name: &'a str,
args: &'a [&'a str],
is_known: impl Fn(&str) -> bool,
) -> Option<(&'a str, &'a [&'a str])> {
if !PREFIX_COMMANDS.contains(&cmd_name) {
return Some((cmd_name, args));
}
let val_opts = [
"-u",
"--user",
"-g",
"--group",
"-C",
"--chdir",
"-p",
"--prompt",
"-h",
"--host",
"-A",
"--askpass",
"-D",
"--chroot",
"-c",
"--class",
"-r",
"--role",
"-t",
"--type",
"-s",
"--signal",
"-n",
"--adjustment",
"-k",
"--kill-after",
];
let mut i = 0;
while i < args.len() {
let a = args[i];
if val_opts.contains(&a) {
i += 2;
continue;
}
if a.starts_with('-') {
i += 1;
continue;
}
if is_known(a) {
return Some((a, &args[i + 1..]));
}
i += 1;
}
None
}