use crate::config::Config;
use crate::finding::{Finding, ScanResult, Severity};
use crate::scanners::{collect_files, is_suppressed_inline, read_file_limited, RuleInfo, Scanner};
use regex::{Regex, RegexSet};
use std::path::Path;
use std::sync::LazyLock;
use std::time::Instant;
struct BashPattern {
id: &'static str,
severity: Severity,
message: &'static str,
remediation: &'static str,
}
static RE_URL_HOST: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)https?://(?:[^@/?#\s]+@)?([^/?#:\s]+)").unwrap());
fn domain_is_allowed(line: &str, allowed: &std::collections::HashSet<&str>) -> bool {
let mut found_any = false;
for cap in RE_URL_HOST.captures_iter(line) {
let host = match cap.get(1) {
Some(m) => m.as_str().to_lowercase(),
None => continue,
};
if host.is_empty() {
continue;
}
found_any = true;
if allowed.contains(host.as_str()) {
continue;
}
let host_is_allowed = allowed.iter().any(|entry| {
host.strip_suffix(entry)
.is_some_and(|prefix| prefix.ends_with('.'))
});
if !host_is_allowed {
return false;
}
}
found_any
}
static PATTERNS: &[BashPattern] = &[
BashPattern {
id: "bash/CAT-A1",
severity: Severity::Error,
message: "Pipe to shell — potential remote code execution",
remediation: "Download to a temp file, verify checksum, then execute explicitly",
},
BashPattern {
id: "bash/CAT-A2",
severity: Severity::Error,
message: "eval of dynamic content — arbitrary code execution risk",
remediation: "Avoid eval; use explicit function calls or case statements",
},
BashPattern {
id: "bash/CAT-A3",
severity: Severity::Error,
message: "Source from URL — executes arbitrary remote shell code",
remediation: "Download to a file, review content, then source explicitly",
},
BashPattern {
id: "bash/CAT-A4",
severity: Severity::Error,
message: "Download to temp file then execute — two-step RCE vector",
remediation: "Use package manager or verified binary download with checksum",
},
BashPattern {
id: "bash/CAT-B1",
severity: Severity::Error,
message: "Access to ~/.ssh/ — SSH key exfiltration risk",
remediation: "SSH keys should never be read by skill scripts",
},
BashPattern {
id: "bash/CAT-B2",
severity: Severity::Error,
message: "Access to ~/.aws/ — AWS credential exfiltration risk",
remediation: "AWS credentials should never be read by skill scripts",
},
BashPattern {
id: "bash/CAT-B3",
severity: Severity::Error,
message: "Access to ~/.kube/config — Kubernetes credential exfiltration risk",
remediation: "Kubeconfig should never be read by skill scripts",
},
BashPattern {
id: "bash/CAT-B4",
severity: Severity::Error,
message: "Environment variable sent as HTTP POST body — exfiltration risk",
remediation: "Never send environment variables to external endpoints",
},
BashPattern {
id: "bash/CAT-B5",
severity: Severity::Error,
message: "env output piped to network tool — full environment exfiltration",
remediation: "Never pipe env output to outbound network tools",
},
BashPattern {
id: "bash/CAT-C1",
severity: Severity::Error,
message: "rm -rf on home or root directory — potentially irreversible destruction",
remediation: "Scope rm operations to specific subdirectories with validated paths",
},
BashPattern {
id: "bash/CAT-C2",
severity: Severity::Error,
message: "dd disk wipe — overwrites storage device",
remediation: "dd to block devices should never appear in skill scripts",
},
BashPattern {
id: "bash/CAT-D1",
severity: Severity::Error,
message: "Netcat reverse shell — opens interactive shell to remote host",
remediation: "Netcat with -e flag is a reverse shell. Remove immediately.",
},
BashPattern {
id: "bash/CAT-D2",
severity: Severity::Error,
message: "Bash TCP reverse shell — /dev/tcp backdoor",
remediation: "Bash /dev/tcp redirection is a reverse shell. Remove immediately.",
},
BashPattern {
id: "bash/CAT-D3",
severity: Severity::Error,
message: "Python socket-based reverse shell pattern",
remediation: "Python socket connect pattern is a known reverse shell. Remove immediately.",
},
BashPattern {
id: "bash/CAT-E1",
severity: Severity::Warning,
message: "sudo shell — unintended privilege escalation",
remediation: "Skills should not require root. Specify exact sudo commands if unavoidable.",
},
BashPattern {
id: "bash/CAT-E2",
severity: Severity::Warning,
message: "SUID bit — persistent privilege escalation vector",
remediation: "Setting SUID bit on binaries is a privilege escalation risk",
},
BashPattern {
id: "bash/CAT-G1",
severity: Severity::Warning,
message: "rm -rf with unquoted variable — empty variable may delete current directory",
remediation: "Quote the variable: rm -rf \"$VARNAME\" and validate it is non-empty first",
},
BashPattern {
id: "bash/CAT-G2",
severity: Severity::Warning,
message: "Shell invoked with variable argument — command injection risk",
remediation: "Avoid bash -c with variable content. Use functions or explicit commands.",
},
BashPattern {
id: "bash/CAT-H1",
severity: Severity::Info,
message: "Outbound HTTP call detected — verify domain is in allowed list",
remediation: "Ensure domain is in oxidized-agentic-audit.toml [allowlist.domains]",
},
];
static PATTERN_SET: LazyLock<RegexSet> = LazyLock::new(|| {
RegexSet::new([
r"(?i)\|\s*(?:/\S+/)?(?:env\s+)?(bash|sh|zsh|fish|ksh|dash)\b",
r#"(?i)\beval\s*["'`\$\(]"#,
r"(?i)\bsource\s*<\s*\(\s*(curl|wget|fetch)",
r"(?i)(curl|wget).+/tmp/.+(?:&&|;)\s*(bash|sh|exec)",
r"(\$\{?HOME\}?|~|/root|/home/[^/\s]+)/\.ssh/",
r"(\$\{?HOME\}?|~|/root|/home/[^/\s]+)/\.aws/",
r"(\$\{?HOME\}?|~|/root|/home/[^/\s]+)/\.kube/config",
r#"(?i)(curl|wget).+(-d\b|--data(?:-binary|-urlencode)?|--post-data)[\s=]["']?[^\n]*\$"#,
r"(?i)\benv\b.+\|\s*(curl|wget|nc)",
r"(?i)\brm\s+(-[rRfF]+\s+){0,3}(\$HOME|~/|/\s*$|\$\{HOME\})",
r"(?i)\bdd\s+if=/dev/(urandom|zero|random)\s+of=/dev/",
r"(?i)\b(nc|ncat)\s+(-[a-zA-Z]+\s+)*(-e|--exec)\s+/bin/",
r"(?i)(bash\s+-i\s+>&?\s*/dev/tcp/|exec\s+\d+[<>]+/dev/tcp/|<>/dev/tcp/)",
r"(?i)python\S*\s+-c\s+.*socket.*connect",
r"(?i)\bsudo\s+(su|bash|sh)\b",
r"\bchmod\s+(?:[ugoa]*\+[ugoa]*s|0?[2467][0-7]{3})\b",
r#"(?i)\brm\s+-[rRfF]+\s+\$[a-zA-Z_][a-zA-Z0-9_]*(?:[^/"\{]|$)"#,
r#"(?i)(bash|sh)\s+-c\s+["']?\$[a-zA-Z_]"#,
r"(?i)(curl|wget)\s+https?://",
])
.unwrap()
});
pub struct BashPatternScanner;
impl Scanner for BashPatternScanner {
fn name(&self) -> &'static str {
"bash_patterns"
}
fn description(&self) -> &'static str {
"Dangerous bash pattern scanner (Categories A-H) — pure Rust regex"
}
fn is_available(&self) -> bool {
true
}
fn scan(&self, path: &Path, config: &Config) -> ScanResult {
let start = Instant::now();
let files = collect_files(path, &["sh", "bash", "zsh"]);
let mut findings = Vec::new();
assert_eq!(
PATTERNS.len(),
PATTERN_SET.len(),
"bash PATTERNS and PATTERN_SET are out of sync — add/remove from both arrays"
);
let allowed_domains: std::collections::HashSet<&str> = config
.allowlist
.domains
.iter()
.filter(|e| !e.is_empty())
.map(String::as_str)
.collect();
for file in &files {
let content = match read_file_limited(file) {
Ok(c) => c,
Err(e) => {
findings.push(Finding {
rule_id: "bash/read-error".to_string(),
message: format!("Could not read file: {}", e),
severity: crate::finding::Severity::Info,
file: Some(file.clone()),
line: None,
column: None,
scanner: "bash_patterns".to_string(),
snippet: None,
suppressed: false,
suppression_reason: None,
remediation: Some(
"Check file permissions and ensure the file is valid UTF-8".to_string(),
),
});
continue;
}
};
for (line_num, line) in content.lines().enumerate() {
let line_num = line_num + 1;
let trimmed = line.trim();
if trimmed.starts_with('#') && !trimmed.starts_with("#!") {
continue;
}
if is_suppressed_inline(line) {
continue;
}
let matches = PATTERN_SET.matches(line);
if !matches.matched_any() {
continue;
}
for idx in matches.iter() {
let pattern = &PATTERNS[idx];
if pattern.id == "bash/CAT-H1" && domain_is_allowed(line, &allowed_domains) {
continue;
}
let snippet = if line.len() > 120 {
let cut = line
.char_indices()
.nth(117)
.map(|(i, _)| i)
.unwrap_or(line.len());
format!("{}...", &line[..cut])
} else {
line.to_string()
};
findings.push(Finding {
rule_id: pattern.id.to_string(),
message: pattern.message.to_string(),
severity: pattern.severity,
file: Some(file.clone()),
line: Some(line_num),
column: None,
scanner: "bash_patterns".to_string(),
snippet: Some(snippet.trim().to_string()),
suppressed: false,
suppression_reason: None,
remediation: Some(pattern.remediation.to_string()),
});
}
}
}
ScanResult {
scanner_name: "bash_patterns".to_string(),
findings,
files_scanned: files.len(),
skipped: false,
skip_reason: None,
error: None,
duration_ms: start.elapsed().as_millis() as u64,
scanner_score: None,
scanner_grade: None,
}
}
}
pub fn rules() -> Vec<RuleInfo> {
PATTERNS
.iter()
.map(|p| RuleInfo {
id: p.id,
severity: match p.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
},
scanner: "bash_patterns",
message: p.message,
remediation: p.remediation,
})
.collect()
}