use crate::linter::{Diagnostic, LintResult, Severity, Span};
const DANGEROUS_MODES: &[&str] = &[
"777", "666", "664", "776", "677", ];
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
for (line_num, line) in source.lines().enumerate() {
if let Some(chmod_col) = find_command(line, "chmod") {
for dangerous_mode in DANGEROUS_MODES {
if contains_mode(line, dangerous_mode) {
let span = Span::new(line_num + 1, chmod_col + 1, line_num + 1, line.len());
let severity = match *dangerous_mode {
"777" | "666" => Severity::Error, _ => Severity::Warning, };
let diag = Diagnostic::new(
"SEC017",
severity,
format!(
"Unsafe file permissions: chmod {} grants excessive permissions - use principle of least privilege",
dangerous_mode
),
span,
);
result.add(diag);
break; }
}
}
}
result
}
fn find_command(line: &str, cmd: &str) -> Option<usize> {
if let Some(pos) = line.find(cmd) {
let before_ok = if pos == 0 {
true
} else {
let char_before = line.chars().nth(pos - 1);
matches!(char_before, Some(' ' | '\t' | ';' | '&' | '|' | '(' | '\n'))
};
let after_idx = pos + cmd.len();
let after_ok = if after_idx >= line.len() {
true
} else {
let char_after = line.chars().nth(after_idx);
matches!(char_after, Some(' ' | '\t' | ';' | '&' | '|' | ')'))
};
if before_ok && after_ok {
return Some(pos);
}
}
None
}
fn is_mode_boundary_before(word: &str, pos: usize) -> bool {
if pos == 0 {
return true;
}
let char_before = word.chars().nth(pos - 1);
!matches!(char_before, Some('0'..='9'))
}
fn is_mode_boundary_after(word: &str, pos: usize, mode_len: usize) -> bool {
let after_idx = pos + mode_len;
if after_idx >= word.len() {
return true;
}
let char_after = word.chars().nth(after_idx);
!matches!(char_after, Some('0'..='9'))
}
fn word_contains_standalone_mode(word: &str, mode: &str) -> bool {
if let Some(pos) = word.find(mode) {
is_mode_boundary_before(word, pos) && is_mode_boundary_after(word, pos, mode.len())
} else {
false
}
}
fn contains_mode(line: &str, mode: &str) -> bool {
for word in line.split_whitespace() {
if word == mode || word == format!("-R {}", mode) || word.ends_with(&format!(" {}", mode)) {
return true;
}
if word.contains(mode) && word_contains_standalone_mode(word, mode) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_SEC017_detects_chmod_777() {
let script = "chmod 777 /etc/passwd";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "SEC017");
assert_eq!(diag.severity, Severity::Error);
assert!(diag.message.contains("777"));
}
#[test]
fn test_SEC017_detects_chmod_666() {
let script = "chmod 666 sensitive.txt";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "SEC017");
assert_eq!(diag.severity, Severity::Error);
}
#[test]
fn test_SEC017_detects_chmod_recursive_777() {
let script = "chmod -R 777 /var/www";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SEC017");
}
#[test]
fn test_SEC017_safe_chmod_755() {
let script = "chmod 755 script.sh";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_SEC017_safe_chmod_644() {
let script = "chmod 644 config.conf";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_SEC017_safe_chmod_600() {
let script = "chmod 600 ~/.ssh/id_rsa";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_SEC017_no_false_positive_comment() {
let script = "# chmod 777 is dangerous";
let result = check(script);
}
#[test]
fn test_SEC017_multiple_dangerous_chmod() {
let script = r#"
chmod 777 /tmp/file1
chmod 666 /tmp/file2
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 2);
}
#[test]
fn test_SEC017_no_auto_fix() {
let script = "chmod 777 file.txt";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert!(diag.fix.is_none(), "SEC017 should not provide auto-fix");
}
#[test]
fn test_SEC017_detects_664_as_warning() {
let script = "chmod 664 shared.txt";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.severity, Severity::Warning); }
#[test]
fn test_SEC017_detects_776() {
let script = "chmod 776 /tmp/shared";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].severity, Severity::Warning);
}
#[test]
fn test_SEC017_detects_677() {
let script = "chmod 677 script.sh";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].severity, Severity::Warning);
}
#[test]
fn test_SEC017_no_chmod_command() {
let script = "echo 777 is a number";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_SEC017_chmod_with_other_options() {
let script = "chmod -v 777 file.txt";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_SEC017_chmod_in_pipeline() {
let script = "echo test | chmod 777 -";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_SEC017_chmod_after_semicolon() {
let script = "ls; chmod 777 file.txt";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_SEC017_chmod_after_and() {
let script = "test -f file && chmod 777 file";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_SEC017_chmod_in_subshell() {
let script = "(chmod 777 file.txt)";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_SEC017_no_false_positive_1777() {
let script = "chmod 1777 /tmp";
let result = check(script);
assert!(result.diagnostics.len() <= 1);
}
#[test]
fn test_SEC017_empty_script() {
let script = "";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_SEC017_whitespace_only() {
let script = " \n\t \n ";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_find_command_no_match() {
let result = find_command("echo hello", "chmod");
assert!(result.is_none());
}
#[test]
fn test_find_command_at_start() {
let result = find_command("chmod 755 file", "chmod");
assert_eq!(result, Some(0));
}
#[test]
fn test_find_command_after_whitespace() {
let result = find_command(" chmod 755 file", "chmod");
assert_eq!(result, Some(2));
}
#[test]
fn test_find_command_not_word_boundary() {
let result = find_command("mychmod 777 file", "chmod");
assert!(result.is_none());
}
#[test]
fn test_contains_mode_exact() {
assert!(contains_mode("chmod 777 file", "777"));
assert!(contains_mode("chmod 666 file", "666"));
}
#[test]
fn test_contains_mode_with_recursive() {
assert!(contains_mode("chmod -R 777 /dir", "777"));
}
#[test]
fn test_contains_mode_not_found() {
assert!(!contains_mode("chmod 755 file", "777"));
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(10))]
#[test]
fn prop_sec017_never_panics(s in ".*") {
let _ = check(&s);
}
#[test]
fn prop_sec017_safe_modes_no_warnings(
mode in "(600|644|755|700)",
file in "[a-z/]{1,20}",
) {
let cmd = format!("chmod {} {}", mode, file);
let result = check(&cmd);
prop_assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn prop_sec017_dangerous_modes_detected(
mode in "(777|666)",
file in "[a-z/]{1,20}",
) {
let cmd = format!("chmod {} {}", mode, file);
let result = check(&cmd);
prop_assert!(!result.diagnostics.is_empty());
prop_assert_eq!(result.diagnostics[0].code.as_str(), "SEC017");
}
}
}