use super::*;
pub(super) fn concurrency_security_findings(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
findings.extend(race_on_shared_map(file, function, lines));
findings.extend(toctou_file_check_then_open(file, function, lines));
findings.extend(shared_slice_append_race(file, function, lines));
findings.extend(goroutine_captures_loop_variable(file, function, lines));
findings.extend(unsafe_pointer_cast(file, function, lines));
findings.extend(cgo_string_lifetime(file, function, lines));
findings.extend(global_rand_source_contention(file, function, lines));
findings
}
fn race_on_shared_map(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
let has_goroutine = lines.iter().any(|line| line.text.contains("go func"));
let has_lock = lines
.iter()
.any(|line| line.text.contains(".Lock()") || line.text.contains(".RLock()"));
if !has_goroutine || has_lock {
return findings;
}
for bl in lines {
if bl.text.contains('[') && bl.text.contains("] =") && !bl.text.contains(":=") {
findings.push(Finding {
rule_id: "race_on_shared_map".into(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: bl.line,
end_line: bl.line,
message: format!(
"function {} mutates a shared map while launching goroutines",
function.fingerprint.name
),
evidence: vec![
format!("map write with goroutines at line {}", bl.line),
"plain Go maps are not safe for concurrent mutation without synchronization"
.into(),
],
});
}
}
findings
}
fn toctou_file_check_then_open(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
for (index, bl) in lines.iter().enumerate() {
if (bl.text.contains("os.Stat(") || bl.text.contains("os.Lstat("))
&& let Some(next) =
lines.iter().skip(index + 1).take(5).find(|line| {
line.text.contains("os.OpenFile(") || line.text.contains("os.Create(")
})
{
findings.push(Finding {
rule_id: "toctou_file_check_then_open".into(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: bl.line,
end_line: next.line,
message: format!(
"function {} checks a path before opening it",
function.fingerprint.name
),
evidence: vec![
format!(
"file check at line {}, open/create at line {}",
bl.line, next.line
),
"the file can change between the check and the open, enabling TOCTOU races"
.into(),
],
});
}
}
findings
}
fn shared_slice_append_race(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
let has_goroutine = lines.iter().any(|line| line.text.contains("go func"));
if !has_goroutine {
return findings;
}
for bl in lines {
if bl.text.contains("= append(") {
findings.push(Finding {
rule_id: "shared_slice_append_race".into(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: bl.line,
end_line: bl.line,
message: format!(
"function {} appends to a shared slice while using goroutines",
function.fingerprint.name
),
evidence: vec![
format!(
"slice append in goroutine-heavy function at line {}",
bl.line
),
"concurrent append can race on slice headers and backing arrays".into(),
],
});
}
}
findings
}
fn goroutine_captures_loop_variable(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
for (index, bl) in lines.iter().enumerate() {
if !(bl.text.contains("for _, ") && bl.text.contains(":= range")) {
continue;
}
let loop_var = bl
.text
.split("for _, ")
.nth(1)
.and_then(|suffix| suffix.split(":=").next())
.map(str::trim)
.unwrap_or("");
if loop_var.is_empty() {
continue;
}
let Some(go_line) = lines
.iter()
.skip(index + 1)
.take(6)
.find(|line| line.text.contains("go func()"))
else {
continue;
};
let uses_loop_var = lines
.iter()
.skip(index + 1)
.take(10)
.any(|line| line.text.contains(loop_var));
if uses_loop_var {
findings.push(Finding {
rule_id: "goroutine_captures_loop_variable".into(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: bl.line,
end_line: go_line.line,
message: format!(
"function {} captures a loop variable in a goroutine closure",
function.fingerprint.name
),
evidence: vec![
format!("range loop at line {}, goroutine at line {}", bl.line, go_line.line),
"capture the value as a parameter so each goroutine sees the intended iteration value"
.into(),
],
});
}
}
findings
}
fn unsafe_pointer_cast(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
for bl in lines {
if bl.text.contains("unsafe.Pointer(uintptr(") {
findings.push(Finding {
rule_id: "unsafe_pointer_cast".into(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: bl.line,
end_line: bl.line,
message: format!(
"function {} uses unsafe.Pointer arithmetic",
function.fingerprint.name
),
evidence: vec![
format!("unsafe cast at line {}", bl.line),
"uintptr values can become dangling pointers".into(),
],
});
}
}
findings
}
fn cgo_string_lifetime(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
let transfers_result_ownership = function
.doc_comment
.as_deref()
.map(str::to_ascii_lowercase)
.is_some_and(|comment| {
comment.contains("caller must free")
|| comment.contains("freed by the caller")
|| comment.contains("caller is responsible for freeing")
});
for bl in lines {
if bl.text.contains("C.CString(") {
let is_result_field_transfer =
transfers_result_ownership && bl.text.contains("result.") && bl.text.contains('=');
if is_result_field_transfer {
continue;
}
let has_free = lines
.iter()
.any(|l| l.text.contains("C.free(") && l.line > bl.line);
if !has_free {
findings.push(Finding {
rule_id: "cgo_string_lifetime".into(),
severity: Severity::Warning,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: bl.line,
end_line: bl.line,
message: format!(
"function {} allocates C string without free",
function.fingerprint.name
),
evidence: vec![
format!("C.CString without C.free at line {}", bl.line),
"leaks C memory not tracked by Go GC".into(),
],
});
}
}
}
findings
}
fn global_rand_source_contention(
file: &ParsedFile,
function: &ParsedFunction,
lines: &[BodyLine],
) -> Vec<Finding> {
let mut findings = Vec::new();
let hot = is_request_path_function(file, function)
|| lines.iter().any(|line| line.text.contains("go func"));
if !hot {
return findings;
}
for alias in import_aliases_for(file, "math/rand") {
for bl in lines {
if bl.text.contains(&format!("{alias}.Intn("))
|| bl.text.contains(&format!("{alias}.Float64("))
|| bl.text.contains(&format!("{alias}.Uint32("))
{
findings.push(Finding {
rule_id: "global_rand_source_contention".into(),
severity: Severity::Info,
path: file.path.clone(),
function_name: Some(function.fingerprint.name.clone()),
start_line: bl.line,
end_line: bl.line,
message: format!(
"function {} uses the global math/rand source on a hot path",
function.fingerprint.name
),
evidence: vec![
format!("global math/rand call at line {}", bl.line),
"the package-global source uses a mutex and can become contended under load"
.into(),
],
});
}
}
}
findings
}
#[cfg(test)]
mod tests {
use std::path::Path;
use crate::analysis::parse_source_file;
use crate::heuristics::go::framework_patterns::body_lines;
use super::cgo_string_lifetime;
macro_rules! go_fixture {
($name:literal) => {
include_str!(concat!("../../../../../tests/fixtures/go/", $name, ".txt"))
};
}
#[test]
fn cgo_string_lifetime_skips_documented_result_ownership_transfer() {
let source = go_fixture!("library_misuse_cgo_string_lifetime_documented_transfer");
let file =
parse_source_file(Path::new("sample.go"), source).expect("go source should parse");
let function = file.functions[0].clone();
let lines = body_lines(&function);
let findings = cgo_string_lifetime(&file, &function, &lines);
assert!(
findings.is_empty(),
"documented ownership transfer should not be flagged"
);
}
#[test]
fn cgo_string_lifetime_still_flags_unfreed_local_allocation() {
let source = go_fixture!("library_misuse_cgo_string_lifetime_local_leak");
let file =
parse_source_file(Path::new("sample.go"), source).expect("go source should parse");
let function = file.functions[0].clone();
let lines = body_lines(&function);
let findings = cgo_string_lifetime(&file, &function, &lines);
assert_eq!(
findings.len(),
1,
"unfreed C strings should still be reported"
);
assert_eq!(findings[0].rule_id, "cgo_string_lifetime");
}
}