use tree_sitter::Node;
use crate::security::detect::bash::ast::get_command_name;
use crate::security::detect::bash::utils::{
best_hit, collect_args, command_basename, path_has_marker, redirect_target, unwrap_command,
};
use crate::sec_bash_detector_rule_metadata;
use crate::security::detect::{EvaluateResult, Rule, Severity, ShellContext};
const CRON_DIR_MARKERS: &[&str] = &[
"/etc/cron.d",
"cron.daily",
"cron.hourly",
"cron.weekly",
"cron.monthly",
"/var/spool/cron",
"etc/crontab",
];
pub struct RuleCronPersistence;
impl RuleCronPersistence {
sec_bash_detector_rule_metadata!(
"bash_cron_persistence",
"Detects cron / at persistence: crontab mutation or writing to cron directories \
to schedule recurring malicious commands",
Severity::Low
);
}
fn analyze_crontab(raw_cmd: &str, args: &[&str]) -> Option<(Severity, String)> {
if args.iter().any(|a| *a == "-l" || *a == "--list" || *a == "-r" || *a == "--remove") {
return None;
}
let sev = if args.is_empty()
|| args.iter()
.any(|a| *a == "-e" || *a == "--edit" || *a == "-i" || *a == "-")
{
Severity::High
} else {
Severity::Medium
};
Some((
sev,
format!("command={:?} crontab mutation args={:?}", raw_cmd, args),
))
}
fn analyze_command(node: &Node, source: &[u8]) -> Option<(Severity, String)> {
let raw_cmd = get_command_name(node, source)?;
let cmd_name = command_basename(raw_cmd);
let args = collect_args(node, source);
let (real_cmd, real_args) = unwrap_command(&cmd_name, &args, &["crontab", "at"])?;
match real_cmd {
"crontab" => analyze_crontab(raw_cmd, real_args),
"at" => {
if real_args.is_empty() {
return None;
}
Some((
Severity::Medium,
format!("command={:?} at job scheduling args={:?}", raw_cmd, real_args),
))
}
_ => None,
}
}
fn analyze_redirect(node: &Node, source: &[u8]) -> Option<(Severity, String)> {
let (_, dest) = redirect_target(node, source)?;
if path_has_marker(dest, CRON_DIR_MARKERS) {
return Some((
Severity::High,
format!("write to cron directory: {:?}", dest),
));
}
None
}
fn analyze(node: &Node, source: &[u8]) -> Option<(Severity, String)> {
match node.kind() {
"command" => analyze_command(node, source),
"file_redirect" => analyze_redirect(node, source),
_ => None,
}
}
#[async_trait::async_trait]
impl Rule for RuleCronPersistence {
fn meta(&self) -> &crate::security::detect::RuleMetadata {
Self::get_meta()
}
async fn evaluate(
&self,
_data: &str,
ctx: &ShellContext,
) -> anyhow::Result<EvaluateResult> {
let best = best_hit(ctx, analyze).await?;
Ok(match best {
Some((sev, evidence)) => EvaluateResult::hit_with_severity(evidence, sev),
None => EvaluateResult::Miss,
})
}
}
#[cfg(test)]
mod test {
use std::collections::HashMap;
use super::*;
use crate::security::detect::bash::BashDetector;
use crate::security::detect::{DetectResult, Detector, ShellContext};
fn get_detector() -> BashDetector {
let ctx = ShellContext::new("/bin/bash", HashMap::new(), 100);
BashDetector::new(ctx, 4096)
}
async fn expect_hit(detector: &BashDetector, payload: &str, expected: Severity) {
let res = detector.detect(payload.to_string(), false, true).await;
match &res {
DetectResult::ThreatDetected(hits) => {
let hit = hits
.iter()
.find(|h| h.rule_meta.name == "bash_cron_persistence")
.unwrap_or_else(|| {
panic!("payload {:?} did not trigger bash_cron_persistence: {:#?}", payload, res)
});
assert_eq!(
hit.final_severity, expected,
"payload {:?}, evidence {:?}",
payload, hit.evidence
);
}
_ => panic!("payload {:?} expected ThreatDetected, got {:#?}", payload, res),
}
}
async fn expect_safe(detector: &BashDetector, payload: &str) {
let res = detector.detect(payload.to_string(), false, true).await;
let hit = match &res {
DetectResult::ThreatDetected(hits) => {
hits.iter().find(|h| h.rule_meta.name == "bash_cron_persistence")
}
_ => None,
};
assert!(
hit.is_none(),
"payload {:?} should not trigger bash_cron_persistence, got {:#?}",
payload, res
);
}
#[tokio::test]
async fn test_cron_mutation() {
let d = get_detector();
expect_hit(&d, "(crontab -l; echo '*/1 * * * * /tmp/x') | crontab -", Severity::High).await;
expect_hit(&d, "crontab -e", Severity::High).await;
expect_hit(&d, "crontab", Severity::High).await;
expect_hit(&d, "echo '* * * * * /bin/sh -i' | crontab -", Severity::High).await;
expect_hit(&d, "sudo crontab -e", Severity::High).await;
}
#[tokio::test]
async fn test_cron_file_write() {
let d = get_detector();
expect_hit(&d, "echo '*/1 * * * * /tmp/x' > /etc/cron.d/evil", Severity::High).await;
expect_hit(&d, "echo hi >> /etc/crontab", Severity::High).await;
expect_hit(&d, "printf '* * * * * root bash /tmp/x' > /var/spool/cron/root", Severity::High).await;
}
#[tokio::test]
async fn test_at_schedule() {
let d = get_detector();
expect_hit(&d, "echo '/tmp/evil.sh' | at now", Severity::Medium).await;
expect_hit(&d, "echo 'ls /tmp' | at midnight", Severity::Medium).await;
}
#[tokio::test]
async fn test_cron_safe() {
let d = get_detector();
expect_safe(&d, "crontab -l").await;
expect_safe(&d, "crontab -r").await;
expect_safe(&d, "ls /etc/cron.d").await;
expect_safe(&d, "echo 'crontab -e'").await;
}
}