fn check_idempotency_line(trimmed: &str, line_num: usize, violations: &mut Vec<Violation>) {
let checks: ComplianceCheck<'_> = &[
(
&|t| is_cmd(t, "mkdir") && !t.contains("-p") && !t.contains("--parents"),
"Non-idempotent: mkdir without -p (fails if dir exists)",
),
(
&|t| {
is_cmd(t, "rm") && !t.contains("-f") && !t.contains("-rf") && !t.contains("--force")
},
"Non-idempotent: rm without -f (fails if file missing)",
),
(
&|t| {
(t.starts_with("ln -s ") || t.contains("&& ln -s "))
&& !t.contains("-sf")
&& !t.contains("-snf")
},
"Non-idempotent: ln -s without -f (fails if link exists)",
),
(
&|t| is_unguarded_adduser(t),
"Non-idempotent: useradd/groupadd without existence check",
),
(
&|t| is_unguarded_git_clone(t),
"Non-idempotent: git clone without directory check",
),
(
&|t| is_unguarded_createdb(t),
"Non-idempotent: createdb without --if-not-exists guard",
),
(
&|t| is_append_to_config(t),
"Non-idempotent: >> append may duplicate content on rerun",
),
];
for (check_fn, message) in checks {
if check_fn(trimmed) {
violations.push(Violation {
rule: RuleId::Idempotency,
line: Some(line_num),
message: message.to_string(),
});
}
}
}
fn is_cmd(trimmed: &str, cmd: &str) -> bool {
trimmed.starts_with(cmd)
&& trimmed
.as_bytes()
.get(cmd.len())
.is_some_and(|&b| b == b' ' || b == b'\t')
|| trimmed.contains(&format!("&& {} ", cmd))
}
fn is_unguarded_adduser(trimmed: &str) -> bool {
(trimmed.starts_with("useradd ") || trimmed.starts_with("groupadd "))
&& !trimmed.contains("|| true")
&& !trimmed.contains("|| :")
&& !trimmed.contains("2>/dev/null")
&& !trimmed.contains("if ")
}
fn is_unguarded_git_clone(trimmed: &str) -> bool {
trimmed.starts_with("git clone ")
&& !trimmed.contains("|| true")
&& !trimmed.contains("if ")
&& !trimmed.contains("[ -d")
&& !trimmed.contains("test -d")
}
fn is_unguarded_createdb(trimmed: &str) -> bool {
if trimmed.starts_with("createdb ") {
return !trimmed.contains("|| true") && !trimmed.contains("2>/dev/null");
}
false
}
fn is_append_to_config(trimmed: &str) -> bool {
if !trimmed.contains(">>") {
return false;
}
let config_patterns = [
".bashrc",
".bash_profile",
".profile",
".zshrc",
"/etc/profile",
"/etc/environment",
".env",
"crontab",
];
config_patterns.iter().any(|p| trimmed.contains(p))
&& !trimmed.contains("grep -q")
&& !trimmed.contains("if ")
}
fn check_security(content: &str) -> RuleResult {
let mut violations = Vec::new();
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
check_security_line(trimmed, i + 1, &mut violations);
}
RuleResult {
rule: RuleId::Security,
passed: violations.is_empty(),
violations,
}
}
fn check_security_line(trimmed: &str, line_num: usize, violations: &mut Vec<Violation>) {
if is_eval_command(trimmed) && (trimmed.contains('$') || trimmed.contains('`')) {
violations.push(Violation {
rule: RuleId::Security,
line: Some(line_num),
message: "SEC001: eval with variable input (injection risk)".to_string(),
});
}
if is_pipe_to_shell(trimmed) {
violations.push(Violation {
rule: RuleId::Security,
line: Some(line_num),
message: "SEC002: piping remote content to shell".to_string(),
});
}
if is_tls_disabled(trimmed) {
violations.push(Violation {
rule: RuleId::Security,
line: Some(line_num),
message: "SEC004: TLS verification disabled".to_string(),
});
}
if is_hardcoded_secret(trimmed) {
violations.push(Violation {
rule: RuleId::Security,
line: Some(line_num),
message: "SEC005: hardcoded secret in variable assignment".to_string(),
});
}
if is_unsafe_tmp(trimmed) {
violations.push(Violation {
rule: RuleId::Security,
line: Some(line_num),
message: "SEC006: unsafe temp file (use mktemp instead of /tmp/ literal)".to_string(),
});
}
if is_sudo_danger(trimmed) {
violations.push(Violation {
rule: RuleId::Security,
line: Some(line_num),
message: "SEC007: sudo with dangerous command and unquoted variable".to_string(),
});
}
}
fn is_pipe_to_shell(trimmed: &str) -> bool {
(trimmed.contains("curl") || trimmed.contains("wget"))
&& (trimmed.contains("| bash") || trimmed.contains("| sh") || trimmed.contains("|sh"))
}
fn is_tls_disabled(trimmed: &str) -> bool {
trimmed.contains("--no-check-certificate")
|| trimmed.contains("--insecure")
|| is_curl_k(trimmed)
}
fn is_curl_k(trimmed: &str) -> bool {
trimmed.contains("curl") && trimmed.split_whitespace().any(|w| w == "-k")
}
fn is_hardcoded_secret(trimmed: &str) -> bool {
const SECRET_PREFIXES: &[&str] = &[
"API_KEY=",
"SECRET=",
"PASSWORD=",
"TOKEN=",
"AWS_SECRET",
"PRIVATE_KEY=",
];
const SECRET_LITERALS: &[&str] = &["sk-", "ghp_", "gho_", "glpat-"];
for prefix in SECRET_PREFIXES {
if let Some(pos) = trimmed.find(prefix) {
let after = &trimmed[pos + prefix.len()..];
if has_literal_value(after) {
return true;
}
}
}
for literal in SECRET_LITERALS {
if trimmed.contains('=') && trimmed.contains(literal) {
return true;
}
}
false
}
fn has_literal_value(after: &str) -> bool {
let val = after.trim_start_matches(['"', '\'']);
!val.is_empty() && !val.starts_with('$') && !val.starts_with('}')
}
fn is_unsafe_tmp(trimmed: &str) -> bool {
trimmed.contains("=\"/tmp/") && !trimmed.contains("mktemp")
}
fn is_sudo_danger(trimmed: &str) -> bool {
if !trimmed.contains("sudo ") {
return false;
}
let has_dangerous = trimmed.contains("rm -rf")
|| trimmed.contains("chmod 777")
|| trimmed.contains("chmod -R")
|| trimmed.contains("chown -R");
let has_unquoted_var =
trimmed.contains(" $") && !trimmed.contains(" \"$") && !trimmed.contains(" '");
has_dangerous && has_unquoted_var
}
fn check_quoting(content: &str) -> RuleResult {
let mut violations = Vec::new();
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
find_unquoted_vars(trimmed, i + 1, &mut violations);
}
RuleResult {
rule: RuleId::Quoting,
passed: violations.is_empty(),
violations,
}
}
fn find_unquoted_vars(trimmed: &str, line_num: usize, violations: &mut Vec<Violation>) {
let chars: Vec<char> = trimmed.chars().collect();
let mut state = QuoteState::default();
let mut j = 0;
while j < chars.len() {
j = scan_quoting_char(&chars, j, trimmed, line_num, &mut state, violations);
}
}
#[derive(Default)]
struct QuoteState {
in_double_quote: bool,
in_single_quote: bool,
}
fn scan_quoting_char(
chars: &[char],
j: usize,
trimmed: &str,
line_num: usize,
state: &mut QuoteState,
violations: &mut Vec<Violation>,
) -> usize {
match chars[j] {
'\\' if !state.in_single_quote => j + 2,
'\'' if !state.in_double_quote => {
state.in_single_quote = !state.in_single_quote;
j + 1
}
'"' if !state.in_single_quote => {
state.in_double_quote = !state.in_double_quote;
j + 1
}
'$' if !state.in_single_quote && j + 1 < chars.len() && chars[j + 1] == '(' => {
skip_subshell(chars, j + 1)
}
'$' if !state.in_single_quote && !state.in_double_quote => {
if is_unquoted_var_expansion(chars, j, trimmed) {
violations.push(Violation {
rule: RuleId::Quoting,
line: Some(line_num),
message: format!("Unquoted variable expansion at column {}", j + 1),
});
skip_var_name(chars, j + 1)
} else {
j + 1
}
}
_ => j + 1,
}
}
fn skip_subshell(chars: &[char], start: usize) -> usize {
let mut depth = 0;
let mut j = start;
while j < chars.len() {
match chars[j] {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return j + 1;
}
}
'\\' => {
j += 1; }
_ => {}
}
j += 1;
}
j }
fn skip_var_name(chars: &[char], start: usize) -> usize {
let mut j = start;
while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') {
j += 1;
}
j
}
include!("rules_is_unquoted.rs");