feat: Enhance resource leak detection by recognizing inner-call release patterns and err-companion guards

This commit is contained in:
elipeter 2026-05-02 21:47:03 -04:00
parent 48bc43e1a6
commit ebe4a15a72
6 changed files with 262 additions and 82 deletions

View file

@ -2473,13 +2473,12 @@ pub(super) fn push_node<'a>(
.is_some_and(|cn| is_parameterized_query_call(cn, code));
// Extract per-argument inner call callees for interprocedural sanitizer resolution.
let mut arg_callees = if kind == StmtKind::Call {
call_ast
.map(|cn| extract_arg_callees(cn, lang, code))
.unwrap_or_default()
} else {
Vec::new()
};
// Also extracted for non-Call kinds (e.g. Assign whose RHS is a call like
// `errs = append(errs, f.Close())`) so the inner-call-release-in-arg branch
// in src/state/transfer.rs sees the closing call.
let mut arg_callees = call_ast
.map(|cn| extract_arg_callees(cn, lang, code))
.unwrap_or_default();
// For assignment sinks (including CallWrapper-wrapped assignments like
// `element.innerHTML = clean(name)`), also extract the RHS callee.

View file

@ -48,23 +48,34 @@ fn find_acquire_nodes(
}
/// Find nodes matching release patterns for a given resource pair.
///
/// Includes both direct release calls (`info.call.callee`) and inner-arg
/// release calls (`info.arg_callees`), so wrapper shapes like Go testify
/// `require.NoError(t, f.Close())` and `errs = append(errs, f.Close())`
/// register the close site even though the outer callee is the wrapper.
fn find_release_nodes(ctx: &AnalysisContext, release_patterns: &[&str]) -> Vec<NodeIndex> {
let matches_release = |callee: &str| -> bool {
let callee_lower = callee.to_ascii_lowercase();
release_patterns.iter().any(|p| {
let pl = p.to_ascii_lowercase();
callee_lower.ends_with(&pl) || callee_lower == pl
})
};
ctx.cfg
.node_indices()
.filter(|&idx| {
let info = &ctx.cfg[idx];
if info.kind != StmtKind::Call {
return false;
}
if let Some(callee) = &info.call.callee {
let callee_lower = callee.to_ascii_lowercase();
release_patterns.iter().any(|p| {
let pl = p.to_ascii_lowercase();
callee_lower.ends_with(&pl) || callee_lower == pl
})
} else {
false
if info.kind == StmtKind::Call && matches_release(callee) {
return true;
}
}
// Inner-call-release-in-arg: any kind, the close lives in an
// argument to the outer wrapper.
info.arg_callees
.iter()
.filter_map(|c| c.as_deref())
.any(matches_release)
})
.collect()
}
@ -95,8 +106,9 @@ fn release_on_all_exit_paths(
// Fall back to path enumeration with null-guard pruning.
let acquire_var = ctx.cfg[acquire].taint.defines.as_deref();
let extra_defines = &ctx.cfg[acquire].taint.extra_defines;
let release_set: HashSet<_> = release_nodes.iter().copied().collect();
all_paths_pass_through(ctx, acquire, exit, &release_set, acquire_var)
all_paths_pass_through(ctx, acquire, exit, &release_set, acquire_var, extra_defines)
}
/// Identify whether a CFG edge is the "null-guard false edge" for the named
@ -143,6 +155,62 @@ fn is_null_guard_false_edge(
edge_kind == null_edge
}
/// Recognise Go's err-companion guard: `if err != nil { return err }` where
/// `err` is a companion define of the acquire (`f, err := os.Open(...)`).
/// On the err-true edge the resource was never actually acquired (acquire
/// returned the zero value), so the path is not a real leak path.
///
/// Returns `true` for the edge that takes the err-non-nil branch. Match is
/// strict: condition must reference exactly one var that lives in the
/// acquire's `extra_defines`, condition_text must compare against `nil`, and
/// the chosen edge must match the err-non-nil polarity.
fn is_err_companion_guard_edge(
ctx: &AnalysisContext,
src: NodeIndex,
edge_kind: EdgeKind,
extra_defines: &[String],
) -> bool {
if extra_defines.is_empty() {
return false;
}
let info = &ctx.cfg[src];
if info.kind != StmtKind::If {
return false;
}
if info.condition_vars.len() != 1 {
return false;
}
let cond_var = &info.condition_vars[0];
if !extra_defines.iter().any(|e| e == cond_var) {
return false;
}
let Some(text) = info.condition_text.as_deref() else {
return false;
};
// Normalise: drop spaces so `err!=nil` and `err != nil` both match.
let collapsed: String = text.chars().filter(|c| !c.is_whitespace()).collect();
let var_eq_nil = format!("{cond_var}==nil");
let var_neq_nil = format!("{cond_var}!=nil");
// Polarity: `err != nil` → err-non-nil branch is the True edge;
// `err == nil` → err-non-nil branch is the False edge.
let err_branch = if collapsed.contains(&var_neq_nil) {
if info.condition_negated {
EdgeKind::False
} else {
EdgeKind::True
}
} else if collapsed.contains(&var_eq_nil) {
if info.condition_negated {
EdgeKind::True
} else {
EdgeKind::False
}
} else {
return false;
};
edge_kind == err_branch
}
/// Check if all paths from `from` to `to` pass through at least one node in `through`,
/// pruning null-guard-false edges for the acquired variable so the canonical
/// `if (var) release(var);` idiom is recognised as a complete release.
@ -152,6 +220,7 @@ fn all_paths_pass_through(
to: NodeIndex,
through: &HashSet<NodeIndex>,
acquire_var: Option<&str>,
extra_defines: &[String],
) -> bool {
use std::collections::VecDeque;
@ -173,6 +242,23 @@ fn all_paths_pass_through(
continue;
}
// Treat a Return-of-err-companion as a passing exit: in Go's
// `f, err := os.Open(...); if err != nil { return err }` shape the
// err-return path does not actually own a resource (acquire returned
// the zero value), so reaching such a Return is not a leak.
let info = &ctx.cfg[node];
if info.kind == StmtKind::Return
&& !extra_defines.is_empty()
&& !info.taint.uses.is_empty()
&& info
.taint
.uses
.iter()
.all(|u| extra_defines.iter().any(|e| e == u))
{
continue;
}
for edge in ctx.cfg.edges(node) {
// Prune null-guard-false edges: those represent "var is null",
// a path on which the resource was never actually acquired.
@ -181,6 +267,12 @@ fn all_paths_pass_through(
{
continue;
}
// Prune Go err-companion guard edges: `if err != nil { return err }`
// after `f, err := os.Open(...)` takes the err branch on which the
// resource handle is the zero value and was never acquired.
if is_err_companion_guard_edge(ctx, node, *edge.weight(), extra_defines) {
continue;
}
let succ = edge.target();
let new_passed = passed || through.contains(&succ);
let state = (succ, new_passed);

View file

@ -152,6 +152,55 @@ pub fn extract_findings(
.collect()
};
// Collect variables released via inner-call-in-arg shape (Go testify
// `require.NoError(t, f.Close())`, `errs = append(errs, f.Close())`,
// JUnit `assertEquals(0, in.read())`). The transfer flips the
// lifecycle to CLOSED on the success branch, but the err-return
// predecessor that ran after the bare acquire (`f, err := os.Open(...)`)
// still merges OPEN at the function-exit join. Mirror the
// `deferred_close_vars` suppression so the OPEN|CLOSED join doesn't
// emit a leak-possible for a resource that has a real release site.
let inner_arg_close_vars: std::collections::HashSet<super::symbol::SymbolId> = {
let pairs = crate::cfg_analysis::rules::resource_pairs(lang);
let mut set = std::collections::HashSet::new();
for (_, ni) in cfg.node_references() {
if ni.in_defer || ni.arg_callees.is_empty() {
continue;
}
let scope = ni.ast.enclosing_func.as_deref();
for arg_callee in &ni.arg_callees {
let Some(arg_callee_text) = arg_callee.as_deref() else {
continue;
};
let Some(dot_idx) = arg_callee_text.rfind('.') else {
continue;
};
let recv_text = &arg_callee_text[..dot_idx];
if recv_text.contains('.') {
continue;
}
let arg_callee_lower = arg_callee_text.to_ascii_lowercase();
let matches_release = pairs.iter().any(|p| {
p.release.iter().any(|r| {
let rl = r.to_ascii_lowercase();
if rl.starts_with('.') {
arg_callee_lower.ends_with(&rl)
} else {
arg_callee_lower.ends_with(&rl) || arg_callee_lower == rl
}
})
});
if !matches_release {
continue;
}
if let Some(sym) = interner.get_scoped(scope, recv_text) {
set.insert(sym);
}
}
}
set
};
for (idx, info) in cfg.node_references() {
// File-level Exit (program termination, no enclosing function).
let is_file_exit = info.kind == StmtKind::Exit && info.ast.enclosing_func.is_none();
@ -207,6 +256,14 @@ pub fn extract_findings(
continue;
}
// Suppress leaks for variables released via inner-call-in-arg
// shape. Mirrors the deferred-close suppression so the
// OPEN-on-err-return / CLOSED-on-success-branch merge at
// function exit does not surface as leak-possible.
if inner_arg_close_vars.contains(&sym) {
continue;
}
// Suppress leaks for variables whose release call lives in a
// nested closure (callback / event handler) outside this
// body's CFG. Common JS/TS shape:

View file

@ -951,6 +951,7 @@ fn compute_succ_states(
kind,
true_polarity,
transfer.interner,
ssa,
);
// Apply validation/predicate to false branch
apply_branch_predicates(
@ -959,6 +960,7 @@ fn compute_succ_states(
kind,
false_polarity,
transfer.interner,
ssa,
);
// PathFact branch narrowing, language-agnostic. The
@ -1189,6 +1191,7 @@ fn apply_branch_predicates(
kind: PredicateKind,
polarity: bool,
interner: &SymbolInterner,
ssa: &SsaBody,
) {
// Validation-like predicates: mark condition vars as validated when polarity is true
if matches!(
@ -1206,11 +1209,43 @@ fn apply_branch_predicates(
// ShellMetaValidated: inverted polarity, the FALSE branch (no metachar
// found) is the validated path; the TRUE branch is the rejection path.
//
// Cap-aware: shell-metachar rejection only proves the value is safe for
// shell-family sinks (it strips `;|&` etc.), not for SQL, path, code-exec,
// SSRF, or other sink classes. Clear `Cap::SHELL_ESCAPE` from the var's
// taint on the validated branch instead of marking it generically
// validated, so non-shell sinks downstream still fire on the residual
// taint caps.
if kind == PredicateKind::ShellMetaValidated && !polarity {
for var in condition_vars {
if let Some(sym) = interner.get(var) {
state.validated_may.insert(sym);
state.validated_must.insert(sym);
let mut to_clear: SmallVec<[SsaValue; 4]> = SmallVec::new();
for (val, _) in state.values.iter() {
if let Some(name) = ssa
.value_defs
.get(val.0 as usize)
.and_then(|vd| vd.var_name.as_deref())
{
if name == var {
to_clear.push(*val);
}
}
}
for val in to_clear {
if let Some(taint) = state.get(val).cloned() {
let new_caps = taint.caps & !Cap::SHELL_ESCAPE;
if new_caps.is_empty() {
state.remove(val);
} else {
state.set(
val,
VarTaint {
caps: new_caps,
origins: taint.origins,
uses_summary: taint.uses_summary,
},
);
}
}
}
}
}