use crate::linter::shell_words::{self, SimpleCommand, WordRole};
use crate::linter::{Diagnostic, Fix, LintResult, Severity, Span};
const DANGEROUS_COMMANDS: &[&str] = &[
"curl", "wget", "ssh", "scp", "git", "rsync", "docker", "kubectl",
];
fn is_comment_line(line: &str) -> bool {
line.trim_start().starts_with('#')
}
struct Offence {
col: usize,
end_col: usize,
cmd: &'static str,
text: String,
}
fn dangerous_command(name: &str) -> Option<&'static str> {
DANGEROUS_COMMANDS.iter().copied().find(|&c| c == name)
}
const REPORTABLE_ROLES: &[WordRole] = &[WordRole::Argument, WordRole::RedirectTarget];
fn command_offence(cmd: &SimpleCommand) -> Option<Offence> {
let name = dangerous_command(cmd.name.as_deref()?)?;
cmd.words
.iter()
.filter(|w| REPORTABLE_ROLES.contains(&w.role))
.flat_map(|w| w.expansions.iter())
.find(|e| !e.quoted)
.map(|e| Offence {
col: e.col,
end_col: e.end_col,
cmd: name,
text: e.text.clone(),
})
}
fn first_offence(line: &str) -> Option<Offence> {
shell_words::simple_commands(line)
.iter()
.filter_map(command_offence)
.min_by_key(|o| o.col)
}
fn create_sec002_diagnostic(o: &Offence, line: usize) -> Diagnostic {
let span = Span::new(line, o.col, line, o.end_col);
Diagnostic::new(
"SEC002",
Severity::Error,
format!(
"Unquoted variable {} in {} command - add quotes",
o.text, o.cmd
),
span,
)
.with_fix(Fix::new(format!("\"{}\"", o.text)))
}
pub fn check(source: &str) -> LintResult {
if source.is_empty() {
return LintResult::new();
}
contract_pre_classify_injection!(source);
let mut result = LintResult::new();
for (idx, line) in source.lines().enumerate() {
if is_comment_line(line) {
continue;
}
if let Some(o) = first_offence(line) {
result.add(create_sec002_diagnostic(&o, idx + 1));
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prop_sec002_comments_never_diagnosed() {
let test_cases = vec![
"# curl $URL",
" # wget $FILE",
"\t# ssh $HOST",
"# git clone $REPO",
];
for code in test_cases {
let result = check(code);
assert_eq!(
result.diagnostics.len(),
0,
"Comments should not be diagnosed: {}",
code
);
}
}
#[test]
fn prop_sec002_quoted_variables_never_diagnosed() {
let test_cases = vec![
r#"curl "${URL}""#,
"wget \"$FILE_PATH\"",
"ssh '$HOST'",
r#"git clone "${REPO}""#,
"docker run \"$IMAGE\"",
];
for code in test_cases {
let result = check(code);
assert_eq!(
result.diagnostics.len(),
0,
"Quoted variables should be OK: {}",
code
);
}
}
#[test]
fn prop_sec002_unquoted_dangerous_always_diagnosed() {
let test_cases = vec![
("curl $URL", "curl"),
("wget $FILE", "wget"),
("ssh $HOST", "ssh"),
("git clone $REPO", "git"),
("docker run $IMAGE", "docker"),
];
for (code, cmd) in test_cases {
let result = check(code);
assert_eq!(
result.diagnostics.len(),
1,
"Unquoted {} should be diagnosed: {}",
cmd,
code
);
assert!(result.diagnostics[0].message.contains(cmd));
}
}
#[test]
fn prop_sec002_safe_commands_never_diagnosed() {
let test_cases = vec!["echo $VAR", "printf $FORMAT", "cat $FILE", "ls $DIR"];
for code in test_cases {
let result = check(code);
assert_eq!(
result.diagnostics.len(),
0,
"Safe commands should not be diagnosed: {}",
code
);
}
}
#[test]
fn prop_sec002_diagnostic_code_always_sec002() {
let code = "curl $A\nwget $B\nssh $C";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(&diagnostic.code, "SEC002");
}
}
#[test]
fn prop_sec002_diagnostic_severity_always_error() {
let code = "curl $A\nwget $B";
let result = check(code);
for diagnostic in &result.diagnostics {
assert_eq!(diagnostic.severity, Severity::Error);
}
}
#[test]
fn prop_sec002_all_diagnostics_have_fix() {
let code = "curl $URL\nwget $FILE";
let result = check(code);
for diagnostic in &result.diagnostics {
assert!(
diagnostic.fix.is_some(),
"All SEC002 diagnostics should have a fix"
);
}
}
#[test]
fn prop_sec002_empty_source_no_diagnostics() {
let result = check("");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn prop_sec002_only_one_diagnostic_per_line() {
let code = "curl $URL $BACKUP";
let result = check(code);
assert_eq!(
result.diagnostics.len(),
1,
"Should only report once per line"
);
}
#[test]
fn test_SEC002_detects_unquoted_curl() {
let script = "curl $URL";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "SEC002");
assert_eq!(diag.severity, Severity::Error);
assert!(diag.message.contains("curl"));
}
#[test]
fn test_SEC002_detects_unquoted_wget() {
let script = "wget $FILE_PATH";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_SEC002_detects_unquoted_ssh() {
let script = "ssh $HOST";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_SEC002_no_warning_with_quotes() {
let script = r#"curl "${URL}""#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_SEC002_no_warning_with_double_quotes() {
let script = "wget \"$FILE_PATH\"";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_SEC002_provides_fix() {
let script = "curl $URL";
let result = check(script);
assert!(result.diagnostics[0].fix.is_some());
let fix = result.diagnostics[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement, "\"$URL\"");
}
#[test]
fn test_SEC002_no_false_positive_comment() {
let script = "# curl $URL";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_mutation_sec002_unquoted_var_start_col_exact() {
let bash_code = "curl $URL"; let result = check(bash_code);
assert_eq!(result.diagnostics.len(), 1);
let span = result.diagnostics[0].span;
assert_eq!(
span.start_col, 6,
"Start column must use correct calculation"
);
}
#[test]
fn test_mutation_sec002_unquoted_var_end_col_exact() {
let bash_code = "curl $URL"; let result = check(bash_code);
assert_eq!(result.diagnostics.len(), 1);
let span = result.diagnostics[0].span;
assert_eq!(
span.end_col, 10,
"End column must cover the whole expansion so --fix splices correctly"
);
}
#[test]
fn test_mutation_sec002_line_num_calculation() {
let bash_code = "# comment\ncurl $URL"; let result = check(bash_code);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(
result.diagnostics[0].span.start_line, 2,
"Line number must use +1, not *1"
);
}
#[test]
fn test_mutation_sec002_column_with_offset() {
let bash_code = " curl $URL"; let result = check(bash_code);
assert_eq!(result.diagnostics.len(), 1);
let span = result.diagnostics[0].span;
assert_eq!(span.start_col, 10, "Must account for leading whitespace");
assert_eq!(span.end_col, 14, "End must cover $URL");
}
#[test]
fn test_mutation_sec002_column_tracking_accuracy() {
let bash_code = "curl $URL"; let result = check(bash_code);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(
result.diagnostics[0].span.start_col, 12,
"Column tracking must increment correctly"
);
}
#[test]
fn test_mutation_sec002_quote_detection_single_quotes() {
let bash_code = "curl '$URL'"; let result = check(bash_code);
assert_eq!(
result.diagnostics.len(),
0,
"Single-quoted variables should be safe"
);
}
#[test]
fn test_mutation_sec002_quote_detection_double_quotes() {
let bash_code = r#"curl "${URL}""#; let result = check(bash_code);
assert_eq!(
result.diagnostics.len(),
0,
"Double-quoted variables should be safe"
);
}
#[test]
fn test_mutation_sec002_variable_detection_underscore() {
let bash_code = "curl $MY_VAR"; let result = check(bash_code);
assert_eq!(
result.diagnostics.len(),
1,
"Should detect variable with underscore"
);
}
#[test]
fn test_GH228_cmdsub_quoted_variable_not_flagged() {
let s = r#"out="$(curl -sSfL "$url" | cut -d' ' -f1)""#;
assert_eq!(check(s).diagnostics.len(), 0);
}
#[test]
fn test_GH228_cmdsub_unquoted_variable_flagged_once() {
let s = r#"out="$(curl -sSfL $url | cut -d' ' -f1)""#;
let r = check(s);
assert_eq!(r.diagnostics.len(), 1);
let d = &r.diagnostics[0];
assert_eq!(d.span.start_col, 19);
assert_eq!(d.span.end_col, 23); assert!(d.message.contains("curl"));
assert!(d.message.contains("$url"));
assert_eq!(d.fix.as_ref().unwrap().replacement, "\"$url\"");
}
#[test]
fn test_GH228_cmdsub_simple_assignment() {
let r = check(r#"x="$(curl $u)""#);
assert_eq!(r.diagnostics.len(), 1);
assert_eq!(r.diagnostics[0].span.start_col, 11);
assert_eq!(r.diagnostics[0].span.end_col, 13);
}
#[test]
fn test_GH228_backtick_substitution_recursed() {
let r = check("x=`curl $u`");
assert_eq!(r.diagnostics.len(), 1);
assert_eq!(r.diagnostics[0].span.start_col, 9);
}
#[test]
fn test_GH228_second_pipeline_stage_is_fresh_command() {
assert_eq!(
check(r#"x="$(echo hi | cut -d' ' -f1)""#).diagnostics.len(),
0
);
}
#[test]
fn test_GH228_braced_expansion_unquoted_is_flagged() {
let r = check("curl ${URL}");
assert_eq!(r.diagnostics.len(), 1);
assert_eq!(r.diagnostics[0].span.start_col, 6);
assert_eq!(r.diagnostics[0].span.end_col, 12);
assert_eq!(
r.diagnostics[0].fix.as_ref().unwrap().replacement,
"\"${URL}\""
);
}
#[test]
fn test_GH228_braced_expansion_quoted_is_not_flagged() {
assert_eq!(check(r#"curl "${URL:-$FALLBACK}""#).diagnostics.len(), 0);
}
#[test]
fn test_GH229_dispatcher_variable_command_not_flagged() {
let s = "sh_c='sh -c'\n\
if [ \"$(id -u)\" -ne 0 ]; then sh_c='sudo -E sh -c'; fi\n\
$sh_c 'docker version'";
assert_eq!(check(s).diagnostics.len(), 0);
}
#[test]
fn test_GH229_command_name_inside_single_quotes_not_matched() {
assert_eq!(check("echo 'docker run' $IMAGE").diagnostics.len(), 0);
assert_eq!(check("echo 'docker run $IMAGE'").diagnostics.len(), 0);
}
#[test]
fn test_GH229_command_name_inside_double_quotes_not_matched() {
assert_eq!(check(r#"echo "docker run" $IMAGE"#).diagnostics.len(), 0);
assert_eq!(check(r#"echo "docker run $IMAGE""#).diagnostics.len(), 0);
}
#[test]
fn test_GH229_braced_dispatcher_not_flagged() {
assert_eq!(check("${SH_C} 'docker version'").diagnostics.len(), 0);
}
#[test]
fn test_GH229_quoted_command_name_still_resolves() {
assert_eq!(check(r#""docker" run $IMG"#).diagnostics.len(), 1);
}
#[test]
fn test_GH228_span_covers_whole_expansion_for_autofix() {
let r = check("curl $URL");
let d = &r.diagnostics[0];
assert_eq!((d.span.start_col, d.span.end_col), (6, 10));
let line = "curl $URL";
let fixed = format!(
"{}{}{}",
&line[..d.span.start_col - 1],
d.fix.as_ref().unwrap().replacement,
&line[d.span.end_col - 1..]
);
assert_eq!(fixed, r#"curl "$URL""#);
}
#[test]
fn test_GH228_columns_are_byte_offsets_not_char_offsets() {
let src = "A=1; curl é$U";
let r = check(src);
assert_eq!(r.diagnostics.len(), 1);
let d = &r.diagnostics[0];
assert_eq!(d.span.start_col, 13);
assert!(src.is_char_boundary(d.span.start_col - 1));
assert!(src.is_char_boundary(d.span.end_col - 1));
}
#[test]
fn test_GH228_braced_modifier_fix_preserves_source_text() {
let r = check("curl ${URL:-https://d}");
assert_eq!(r.diagnostics.len(), 1);
assert_eq!(
r.diagnostics[0].fix.as_ref().unwrap().replacement,
"\"${URL:-https://d}\""
);
}
#[test]
fn test_GH229_sudo_wrapper_resolves_to_real_command() {
assert_eq!(
check("sudo -E docker run $IMG").diagnostics[0].span.start_col,
20
);
}
#[test]
fn test_GH229_timeout_numeric_operand_skipped() {
assert_eq!(check("timeout 5 curl $U").diagnostics[0].span.start_col, 16);
}
#[test]
fn test_GH229_wrapper_operand_rule_does_not_swallow_ssh() {
assert_eq!(check("sudo ssh $HOST").diagnostics.len(), 1);
}
#[test]
fn test_GH229_wrapper_argument_named_docker_is_not_a_command() {
assert_eq!(check("sudo usermod -aG docker $USER").diagnostics.len(), 0);
}
#[test]
fn test_GH229_numeric_first_word_is_not_a_wrapper_operand() {
assert_eq!(
check(" 18 kubectl set image deployment/$app_name")
.diagnostics
.len(),
0
);
}
#[test]
fn test_GH229_absolute_path_command_matches_by_basename() {
assert_eq!(check("/usr/bin/curl $U").diagnostics[0].span.start_col, 15);
}
#[test]
fn test_GH229_assignment_prefix_before_command_is_not_an_argument() {
assert_eq!(check(r#"VAR=$X curl "$URL""#).diagnostics.len(), 0);
}
#[test]
fn test_GH229_assignment_shaped_argument_after_command_is_flagged() {
let r = check("kubectl set image deployment/myapp myapp=myapp:$VERSION");
assert_eq!(r.diagnostics.len(), 1);
assert_eq!(r.diagnostics[0].span.start_col, 48);
}
#[test]
fn test_GH229_reserved_words_do_not_become_command_names() {
assert_eq!(
check("if ! curl $URL; then :; fi").diagnostics[0].span.start_col,
11
);
}
#[test]
fn test_GH229_for_loop_word_is_command_name_of_its_own() {
assert_eq!(
check(r#"for u in $URLS; do curl "$u"; done"#)
.diagnostics
.len(),
0
);
}
#[test]
fn test_GH228_leftmost_offence_wins_over_redirect_target() {
let r = check("curl $URL > $OUT");
assert_eq!(r.diagnostics.len(), 1);
assert_eq!(r.diagnostics[0].span.start_col, 6);
}
#[test]
fn test_GH228_unquoted_redirect_target_is_reported() {
let r = check("curl -sSfL https://example.com/x > $OUT");
assert_eq!(r.diagnostics.len(), 1);
let d = &r.diagnostics[0];
assert_eq!(d.severity, Severity::Error);
assert_eq!((d.span.start_col, d.span.end_col), (36, 40));
assert_eq!(d.fix.as_ref().unwrap().replacement, "\"$OUT\"");
assert!(d.message.contains("curl"));
}
#[test]
fn test_GH228_append_redirect_target_is_reported() {
let r = check("wget -q https://x -O- >> $LOG");
assert_eq!(r.diagnostics.len(), 1);
assert_eq!(r.diagnostics[0].span.start_col, 26);
}
#[test]
fn test_GH228_quoted_redirect_target_is_not_reported() {
assert_eq!(check(r#"curl -sSfL https://x > "$OUT""#).diagnostics.len(), 0);
}
#[test]
fn test_GH228_redirect_target_of_safe_command_is_not_reported() {
assert_eq!(check("echo hi > $OUT").diagnostics.len(), 0);
assert_eq!(check("cat f > $OUT").diagnostics.len(), 0);
}
#[test]
fn test_GH228_eval_prefix_still_reports_the_real_command() {
let r = check("eval curl $URL");
assert_eq!(r.diagnostics.len(), 1);
assert_eq!(r.diagnostics[0].span.start_col, 11);
assert!(r.diagnostics[0].message.contains("curl"));
let r = check("eval ssh $HOST uptime");
assert_eq!(r.diagnostics.len(), 1);
assert!(r.diagnostics[0].message.contains("ssh"));
}
#[test]
fn test_GH228_eval_of_a_variable_is_not_flagged() {
assert_eq!(check(r#"eval "$cmd""#).diagnostics.len(), 0);
assert_eq!(check("eval $CMD").diagnostics.len(), 0);
assert_eq!(check(r#"eval "$sh_c" 'docker version'"#).diagnostics.len(), 0);
}
#[test]
fn test_GH228_find_exec_reports_the_executed_command() {
let r = check(r"find . -exec curl $URL {} \;");
assert_eq!(r.diagnostics.len(), 1);
assert_eq!(r.diagnostics[0].span.start_col, 19);
assert!(r.diagnostics[0].message.contains("curl"));
}
#[test]
fn test_GH228_find_exec_safe_command_is_not_flagged() {
assert_eq!(check(r"find . -exec sed -i s/a/b/ $F \;").diagnostics.len(), 0);
assert_eq!(check(r#"find . -exec curl "$URL" {} \;"#).diagnostics.len(), 0);
}
#[test]
fn test_GH228_sh_dash_c_script_string_keeps_the_finding() {
let r = check("sh -c 'curl '$URL");
assert_eq!(r.diagnostics.len(), 1);
let d = &r.diagnostics[0];
assert_eq!(d.severity, Severity::Error);
assert_eq!((d.span.start_col, d.span.end_col), (14, 18));
assert!(d.message.contains("curl"));
assert_eq!(d.fix.as_ref().unwrap().replacement, "\"$URL\"");
}
#[test]
fn test_GH229_sh_dash_c_fully_literal_script_is_not_flagged() {
assert_eq!(check("sh -c 'docker version'").diagnostics.len(), 0);
assert_eq!(check("sudo -E sh -c 'docker version'").diagnostics.len(), 0);
assert_eq!(check(r#"sh -c "$SCRIPT""#).diagnostics.len(), 0);
assert_eq!(check("sh -c $CMD").diagnostics.len(), 0);
assert_eq!(check(r#"bash -c "curl $URL""#).diagnostics.len(), 0);
}
#[test]
fn test_GH228_script_operand_reported_once_not_twice() {
assert_eq!(check("sh -c 'curl '$URL' '$OTHER").diagnostics.len(), 1);
}
#[test]
fn test_GH228_quoted_header_then_unquoted_url() {
let r = check(r#"curl -H "Authorization: Bearer $TOKEN" $URL"#);
assert_eq!(r.diagnostics.len(), 1);
assert_eq!(r.diagnostics[0].span.start_col, 40);
}
#[test]
fn test_GH228_trailing_comment_is_not_scanned() {
assert_eq!(check(r#"curl "$a" # curl $b"#).diagnostics.len(), 0);
}
#[test]
fn test_GH228_escaped_dollar_is_not_an_expansion() {
assert_eq!(check(r"curl \$URL").diagnostics.len(), 0);
}
#[test]
fn test_GH228_arithmetic_expansion_is_not_word_split() {
assert_eq!(check("curl $(( x + $y ))").diagnostics.len(), 0);
}
#[test]
fn test_GH228_command_substitution_argument_is_not_a_variable() {
assert_eq!(check("curl $(get_url)").diagnostics.len(), 0);
}
#[test]
fn test_GH228_inner_command_not_dangerous_no_finding() {
assert_eq!(check(r#"curl "$(echo $url)""#).diagnostics.len(), 0);
}
#[test]
fn test_GH229_command_substring_is_not_a_command() {
assert_eq!(check("curl_handler $URL").diagnostics.len(), 0);
}
#[test]
fn test_GH228_unterminated_double_quote_does_not_panic() {
assert_eq!(check(r#"curl "$url"#).diagnostics.len(), 0);
}
#[test]
fn test_GH228_unterminated_single_quote_does_not_panic() {
let _ = check("curl '");
}
#[test]
fn test_GH228_unterminated_command_substitution_does_not_panic() {
let r = check("x=$(curl $u");
assert_eq!(r.diagnostics.len(), 1);
}
#[test]
fn test_GH228_unterminated_brace_expansion_does_not_panic() {
let _ = check("curl ${URL");
}
#[test]
fn test_GH228_deeply_nested_substitution_terminates() {
let s = format!("curl {}$u{}", "$(".repeat(64), ")".repeat(64));
let _ = check(&s);
}
use proptest::prelude::*;
proptest! {
#[test]
fn prop_GH228_quoting_never_adds_a_finding(
name in "[A-Za-z_][A-Za-z0-9_]{0,8}",
cmd in prop::sample::select(DANGEROUS_COMMANDS),
) {
let bare = check(&format!("{} ${}", cmd, name)).diagnostics.len();
let quoted = check(&format!("{} \"${}\"", cmd, name)).diagnostics.len();
prop_assert!(quoted <= bare);
prop_assert_eq!(quoted, 0);
}
#[test]
fn prop_GH229_variable_command_never_flagged(
var in "[a-z_][a-z0-9_]{0,8}",
arg in "[a-z ]{1,20}",
) {
prop_assert_eq!(check(&format!("${} '{}'", var, arg)).diagnostics.len(), 0);
}
#[test]
fn prop_GH228_spans_are_char_boundaries(src in ".{0,200}") {
for d in check(&src).diagnostics {
let line = src.lines().nth(d.span.start_line - 1).unwrap_or("");
prop_assert!(d.span.start_col >= 1 && d.span.end_col > d.span.start_col);
prop_assert!(d.span.end_col <= line.len() + 1);
prop_assert!(line.is_char_boundary(d.span.start_col - 1));
prop_assert!(line.is_char_boundary(d.span.end_col - 1));
}
}
#[test]
fn prop_GH228_total_and_bounded(src in ".{0,400}") {
let r = check(&src);
prop_assert!(r.diagnostics.len() <= src.lines().count());
}
}
}