use crate::linter::LintResult;
use crate::linter::{Diagnostic, Severity, Span};
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
for (line_num, line) in source.lines().enumerate() {
let trimmed = line.trim();
let code_only = if let Some(pos) = trimmed.find('#') {
&trimmed[..pos]
} else {
trimmed
};
let code_only = code_only.trim();
if code_only.contains("eval") && code_only.contains("jq") {
let span = Span::new(line_num + 1, 1, line_num + 1, line.len());
let diag = Diagnostic::new(
"SEC012",
Severity::Error,
"Unsafe deserialization: eval with jq can execute arbitrary code from JSON - validate data before eval or use safer parsing",
span,
);
result.add(diag);
}
if (code_only.contains("source") || code_only.starts_with('.'))
&& code_only.contains("<(curl")
{
let span = Span::new(line_num + 1, 1, line_num + 1, line.len());
let diag = Diagnostic::new(
"SEC012",
Severity::Error,
"Unsafe deserialization: sourcing remote content without verification - download, verify checksum, then source",
span,
);
result.add(diag);
}
if (code_only.contains("source") || code_only.starts_with('.'))
&& code_only.contains("<(wget")
{
let span = Span::new(line_num + 1, 1, line_num + 1, line.len());
let diag = Diagnostic::new(
"SEC012",
Severity::Error,
"Unsafe deserialization: sourcing remote content without verification - download, verify checksum, then source",
span,
);
result.add(diag);
}
if code_only.contains("eval") && code_only.contains("$(curl") {
let span = Span::new(line_num + 1, 1, line_num + 1, line.len());
let diag = Diagnostic::new(
"SEC012",
Severity::Error,
"Unsafe deserialization: eval of remote content without verification - download, verify, validate, then execute",
span,
);
result.add(diag);
}
if code_only.contains("eval") && code_only.contains("$(wget") {
let span = Span::new(line_num + 1, 1, line_num + 1, line.len());
let diag = Diagnostic::new(
"SEC012",
Severity::Error,
"Unsafe deserialization: eval of remote content without verification - download, verify, validate, then execute",
span,
);
result.add(diag);
}
if code_only.contains("eval") && code_only.contains("yq") {
let span = Span::new(line_num + 1, 1, line_num + 1, line.len());
let diag = Diagnostic::new(
"SEC012",
Severity::Error,
"Unsafe deserialization: eval with yq can execute arbitrary code from YAML - validate data before eval or use safer parsing",
span,
);
result.add(diag);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_SEC012_detects_eval_jq() {
let script = r#"#!/bin/bash
eval $(echo "$JSON" | jq -r '. | to_entries[] | "\(.key)=\(.value)"')
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "SEC012");
assert_eq!(diag.severity, Severity::Error);
assert!(diag.message.contains("jq"));
assert!(diag.message.contains("eval"));
}
#[test]
fn test_SEC012_detects_source_curl() {
let script = r#"#!/bin/bash
source <(curl https://example.com/config.sh)
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "SEC012");
assert_eq!(diag.severity, Severity::Error);
assert!(diag.message.contains("sourcing remote"));
}
#[test]
fn test_SEC012_detects_source_wget() {
let script = r#"#!/bin/bash
. <(wget -qO- https://example.com/setup.sh)
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "SEC012");
assert_eq!(diag.severity, Severity::Error);
assert!(diag.message.contains("sourcing remote"));
}
#[test]
fn test_SEC012_detects_eval_curl() {
let script = r#"#!/bin/bash
eval $(curl https://example.com/script.sh)
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "SEC012");
assert_eq!(diag.severity, Severity::Error);
assert!(diag.message.contains("eval of remote"));
}
#[test]
fn test_SEC012_detects_eval_wget() {
let script = r#"#!/bin/bash
eval $(wget -qO- https://example.com/vars.sh)
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "SEC012");
assert_eq!(diag.severity, Severity::Error);
assert!(diag.message.contains("eval of remote"));
}
#[test]
fn test_SEC012_detects_eval_yq() {
let script = r#"#!/bin/bash
eval $(yq '.config' config.yaml)
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "SEC012");
assert_eq!(diag.severity, Severity::Error);
assert!(diag.message.contains("yq"));
}
#[test]
fn test_SEC012_passes_safe_jq() {
let script = r#"#!/bin/bash
# Safe: parse JSON without eval
CONFIG=$(echo "$JSON" | jq -r '.config')
echo "$CONFIG"
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0, "Safe jq usage should pass");
}
#[test]
fn test_SEC012_passes_safe_source() {
let script = r#"#!/bin/bash
# Safe: source local verified file
source ./config.sh
. /etc/bashrc
"#;
let result = check(script);
assert_eq!(result.diagnostics.len(), 0, "Safe source usage should pass");
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(10))]
#[test]
fn prop_sec012_never_panics(s in ".*") {
let _ = check(&s);
}
#[test]
fn prop_sec012_detects_eval_jq(
json_var in "[A-Z_]{1,20}",
) {
let script = format!("eval $(echo \"${}\" | jq -r '.')", json_var);
let result = check(&script);
prop_assert_eq!(result.diagnostics.len(), 1);
prop_assert_eq!(result.diagnostics[0].code.as_str(), "SEC012");
}
#[test]
fn prop_sec012_detects_source_curl(
url in "https?: ) {
let script = format!("source <(curl {})", url);
let result = check(&script);
prop_assert_eq!(result.diagnostics.len(), 1);
prop_assert_eq!(result.diagnostics[0].code.as_str(), "SEC012");
}
}
}