Skip to content

Commit 60d9219

Browse files
fix(assail): null-check-aware UncheckedAllocation + precise eval detectors (#134)
Three **precision fixes** to the `assail` analyzers that produced false positives across the estate — found while triaging hyperpolymath/proven#68 and JoshuaJewell/paint-type#86, and the root cause of why genuine fixes didn't clear. All conservative: **no new false negatives**. ## 1. UncheckedAllocation is now actually "unchecked"-aware (C) The detector flagged **every** `malloc(...)` regardless of a NULL check, and emitted a *line-less, file-level* finding. Now it: - scans per line and **skips a malloc whose result is NULL-checked** within a short window (`if (p == NULL)`, `if (!p)`, `nullptr`); - attaches a **line number** — which also lets an inline `// panic-attack: accepted` marker suppress a reviewed site (previously impossible: marker suppression is line-gated). This is precisely why a real null-check fix (proven `stubs.c`) never cleared before. ## 2. eval() detectors are word-boundary aware (JS / Python) `contains("eval(")` matched FFI symbol names like `proven_calculator_eval(`. Now `\beval\s*\(` (and `\b(?:eval|exec)\s*\(` for Python). Genuine `eval(` still flagged. ## 3. Shell `eval` no longer matches the `--eval` CLI flag `contains("eval ")` matched `--eval`. Now the eval builtin is matched only in statement position — `(?m)(?:^|[\s;&|(])eval[ \t]`. `--eval`/`-eval` no longer flagged; the real shell `eval` builtin still is. ## Verification - 4 new tests (`tests/analyzer_tests.rs`): null-checked malloc skipped; genuinely-unchecked still flagged; shell `--eval` flag not flagged but builtin is; FFI `*_eval(` not flagged but real `eval()` is. - Full analyzer suite **17/17 green**, zero warnings. - **End-to-end**: rebuilt 2.5.5 and re-scanned — - **proven**: 1 → **0** active Critical/High (`stubs.c` clears; the genuine fix finally registers). - **paint-type**: 36 → **35** (the gossamer `--eval` benchmark FP clears; the 3 irreducible `believe_me` axioms + 32 genuinely-unsafe vendored Zig FFI **correctly remain** — no over-suppression). Refs #32, hyperpolymath/proven#68. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 926c507 commit 60d9219

2 files changed

Lines changed: 201 additions & 17 deletions

File tree

src/assail/analyzer.rs

Lines changed: 95 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,9 @@ static RE_UNCHECKED_MALLOC: OnceLock<Regex> = OnceLock::new();
240240
static RE_ELIXIR_APPLY: OnceLock<Regex> = OnceLock::new();
241241
static RE_PONY_FFI: OnceLock<Regex> = OnceLock::new();
242242
static RE_SHELL_UNQUOTED_VAR: OnceLock<Regex> = OnceLock::new();
243+
static RE_SHELL_EVAL_BUILTIN: OnceLock<Regex> = OnceLock::new();
244+
static RE_WORD_EVAL_CALL: OnceLock<Regex> = OnceLock::new();
245+
static RE_WORD_EVAL_EXEC_CALL: OnceLock<Regex> = OnceLock::new();
243246
static RE_HTTP_URL: OnceLock<Regex> = OnceLock::new();
244247
static RE_HTTP_LOCALHOST: OnceLock<Regex> = OnceLock::new();
245248
static RE_HTTP_JSONLD_IDENTIFIER: OnceLock<Regex> = OnceLock::new();
@@ -259,6 +262,59 @@ static RE_HARDCODED_SECRET: OnceLock<Regex> = OnceLock::new();
259262
/// Does not handle OCaml `(* *)` or Forth `\` — edge cases for later.
260263
static RE_TODO_COMMENT: OnceLock<Regex> = OnceLock::new();
261264

265+
/// Heuristic: is the `malloc` on `lines[idx]` NULL-checked within a short
266+
/// window of following lines?
267+
///
268+
/// An "UncheckedAllocation" finding should only fire when the result is *not*
269+
/// guarded. This recognises the common C idioms (`if (p == NULL)`,
270+
/// `if (!p)`, `if (p == nullptr)`) on the lvalue assigned from `malloc`.
271+
///
272+
/// Deliberately conservative to avoid false negatives in a security scanner:
273+
/// a site is treated as checked only on a clear NULL-guard signal that
274+
/// references the allocated variable (or, when no lvalue can be parsed, any
275+
/// NULL/nullptr token in the window). Genuinely-unchecked allocations — the
276+
/// real finding — are still reported.
277+
fn c_alloc_result_is_null_checked(lines: &[&str], idx: usize) -> bool {
278+
let line = lines[idx];
279+
// Parse the lvalue assigned from malloc: the last identifier left of '='.
280+
// e.g. `cache` in `LRUCache* cache = malloc(...)`, `data` in
281+
// `buf->data = malloc(...)`.
282+
let var = if line.contains('=') {
283+
line.split('=')
284+
.next()
285+
.and_then(|lhs| {
286+
lhs.split(|c: char| !c.is_alphanumeric() && c != '_')
287+
.filter(|s| !s.is_empty())
288+
.last()
289+
})
290+
.map(str::to_string)
291+
} else {
292+
None
293+
};
294+
295+
let end = (idx + 7).min(lines.len());
296+
for l in &lines[idx..end] {
297+
let null_token = l.contains("NULL") || l.contains("nullptr");
298+
match &var {
299+
Some(v) if !v.is_empty() => {
300+
if null_token && l.contains(v.as_str()) {
301+
return true;
302+
}
303+
// `if (!p)` / `if (! p)` negation guard on the variable.
304+
if l.trim_start().starts_with("if") && l.contains(&format!("!{}", v)) {
305+
return true;
306+
}
307+
}
308+
_ => {
309+
if null_token {
310+
return true;
311+
}
312+
}
313+
}
314+
}
315+
false
316+
}
317+
262318
pub struct Analyzer {
263319
target: PathBuf,
264320
language: Language,
@@ -1662,20 +1718,29 @@ impl Analyzer {
16621718
stats.threading_constructs += content.matches("pthread_").count();
16631719
stats.threading_constructs += content.matches("std::thread").count();
16641720

1721+
// UncheckedAllocation: report only mallocs whose result is NOT
1722+
// NULL-checked within a short window of following lines. Scanning
1723+
// per-line (rather than the whole file) lets us (a) skip guarded
1724+
// allocations — `if (p == NULL)` / `if (!p)` — instead of flagging
1725+
// every malloc, and (b) attach a line number, which in turn lets an
1726+
// inline `// panic-attack: accepted` marker suppress a reviewed site.
16651727
let unchecked_malloc = RE_UNCHECKED_MALLOC
16661728
.get_or_init(|| Regex::new(r"malloc\([^)]+\)\s*;").expect("static regex is valid"));
1667-
if unchecked_malloc.is_match(content) {
1668-
weak_points.push(WeakPoint {
1669-
file: None,
1670-
line: None,
1671-
category: WeakPointCategory::UncheckedAllocation,
1672-
location: Some(file_path.to_string()),
1673-
severity: Severity::Critical,
1674-
description: format!("Unchecked malloc in {}", file_path),
1675-
recommended_attack: vec![AttackAxis::Memory],
1676-
suppressed: false,
1677-
test_context: None,
1678-
});
1729+
let c_lines: Vec<&str> = content.lines().collect();
1730+
for (idx, line) in c_lines.iter().enumerate() {
1731+
if unchecked_malloc.is_match(line) && !c_alloc_result_is_null_checked(&c_lines, idx) {
1732+
weak_points.push(WeakPoint {
1733+
file: None,
1734+
line: Some((idx + 1) as u32),
1735+
category: WeakPointCategory::UncheckedAllocation,
1736+
location: Some(file_path.to_string()),
1737+
severity: Severity::Critical,
1738+
description: format!("Unchecked malloc in {}", file_path),
1739+
recommended_attack: vec![AttackAxis::Memory],
1740+
suppressed: false,
1741+
test_context: None,
1742+
});
1743+
}
16791744
}
16801745

16811746
// gets() — no bounds checking, classic buffer overflow vector
@@ -2035,7 +2100,11 @@ impl Analyzer {
20352100
});
20362101
}
20372102

2038-
if content.contains("eval(") || content.contains("exec(") {
2103+
// Word-boundary match so identifiers like `safe_eval(` / `myexec(`
2104+
// are not flagged as a Python eval/exec call.
2105+
let py_eval_exec = RE_WORD_EVAL_EXEC_CALL
2106+
.get_or_init(|| Regex::new(r"\b(?:eval|exec)\s*\(").expect("static regex is valid"));
2107+
if py_eval_exec.is_match(content) {
20392108
weak_points.push(WeakPoint {
20402109
file: None,
20412110
line: None,
@@ -2234,8 +2303,12 @@ impl Analyzer {
22342303
stats.threading_constructs += content.matches("Worker(").count();
22352304
stats.threading_constructs += content.matches("new Worker").count();
22362305

2237-
// Skip eval() check for browser extensions using DevTools API
2238-
if content.contains("eval(") && !self.browser_extension {
2306+
// Skip eval() check for browser extensions using DevTools API.
2307+
// Word-boundary match so FFI symbol names like `proven_calculator_eval(`
2308+
// are not flagged as a JS `eval()` call.
2309+
let word_eval = RE_WORD_EVAL_CALL
2310+
.get_or_init(|| Regex::new(r"\beval\s*\(").expect("static regex is valid"));
2311+
if word_eval.is_match(content) && !self.browser_extension {
22392312
weak_points.push(WeakPoint {
22402313
file: None,
22412314
line: None,
@@ -4475,8 +4548,13 @@ impl Analyzer {
44754548
stats.io_operations += content.matches("curl ").count();
44764549
stats.io_operations += content.matches("wget ").count();
44774550

4478-
// Command injection via eval
4479-
if content.contains("eval ") || content.contains("eval\t") {
4551+
// Command injection via the shell `eval` builtin. Match `eval` only in
4552+
// statement position (start of line, or after whitespace/`;`/`&`/`|`/`(`)
4553+
// so that `--eval` / `-eval` CLI flags and identifiers like `myeval`
4554+
// are not flagged.
4555+
let shell_eval = RE_SHELL_EVAL_BUILTIN
4556+
.get_or_init(|| Regex::new(r"(?m)(?:^|[\s;&|(])eval[ \t]").expect("static regex is valid"));
4557+
if shell_eval.is_match(content) {
44804558
weak_points.push(WeakPoint {
44814559
file: None,
44824560
line: None,

tests/analyzer_tests.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,109 @@ fn main() {
358358
assert!(stats.lines > 0);
359359
Ok(())
360360
}
361+
362+
// --- Track C precision fixes (2026-06-23) ---------------------------------
363+
364+
#[test]
365+
fn test_c_analyzer_skips_null_checked_malloc() -> Result<(), Box<dyn std::error::Error>> {
366+
let dir = TempDir::new()?;
367+
let file = write_test_file(
368+
&dir,
369+
"checked.c",
370+
r#"
371+
#include <stdlib.h>
372+
373+
int* make(void) {
374+
int* ptr = malloc(100);
375+
if (ptr == NULL) {
376+
return NULL;
377+
}
378+
*ptr = 42;
379+
return ptr;
380+
}
381+
"#,
382+
)?;
383+
let report = assail::analyze(&file)?;
384+
let unchecked = report
385+
.weak_points
386+
.iter()
387+
.any(|wp| matches!(wp.category, WeakPointCategory::UncheckedAllocation));
388+
assert!(!unchecked, "NULL-checked malloc must NOT be flagged");
389+
Ok(())
390+
}
391+
392+
#[test]
393+
fn test_c_analyzer_still_flags_genuinely_unchecked_malloc(
394+
) -> Result<(), Box<dyn std::error::Error>> {
395+
let dir = TempDir::new()?;
396+
let file = write_test_file(
397+
&dir,
398+
"unchecked.c",
399+
r#"
400+
#include <stdlib.h>
401+
int main() {
402+
int* ptr = malloc(100);
403+
*ptr = 42;
404+
return 0;
405+
}
406+
"#,
407+
)?;
408+
let report = assail::analyze(&file)?;
409+
let unchecked = report
410+
.weak_points
411+
.iter()
412+
.any(|wp| matches!(wp.category, WeakPointCategory::UncheckedAllocation));
413+
assert!(unchecked, "unguarded malloc must still be flagged");
414+
Ok(())
415+
}
416+
417+
#[test]
418+
fn test_shell_eval_builtin_flagged_but_not_cli_flag(
419+
) -> Result<(), Box<dyn std::error::Error>> {
420+
let dir = TempDir::new()?;
421+
// `--eval` is a CLI flag, not the shell eval builtin.
422+
let flag = write_test_file(&dir, "flag.sh", "#!/bin/sh\nmybinary --eval \"window.close()\"\n")?;
423+
let report = assail::analyze(&flag)?;
424+
let flagged = report
425+
.weak_points
426+
.iter()
427+
.any(|wp| matches!(wp.category, WeakPointCategory::CommandInjection));
428+
assert!(!flagged, "--eval CLI flag must NOT be flagged as shell eval");
429+
430+
// The real eval builtin must still be flagged.
431+
let builtin = write_test_file(&dir, "real.sh", "#!/bin/sh\neval \"$user_input\"\n")?;
432+
let report2 = assail::analyze(&builtin)?;
433+
let flagged2 = report2
434+
.weak_points
435+
.iter()
436+
.any(|wp| matches!(wp.category, WeakPointCategory::CommandInjection));
437+
assert!(flagged2, "shell eval builtin must still be flagged");
438+
Ok(())
439+
}
440+
441+
#[test]
442+
fn test_js_ffi_eval_symbol_not_flagged() -> Result<(), Box<dyn std::error::Error>> {
443+
let dir = TempDir::new()?;
444+
// An FFI symbol named *_eval is not a JS eval() call.
445+
let ffi = write_test_file(
446+
&dir,
447+
"ffi.js",
448+
"const r = lib.proven_calculator_eval(buf, buf.length);\n",
449+
)?;
450+
let report = assail::analyze(&ffi)?;
451+
let flagged = report
452+
.weak_points
453+
.iter()
454+
.any(|wp| matches!(wp.category, WeakPointCategory::DynamicCodeExecution));
455+
assert!(!flagged, "FFI symbol *_eval must NOT be flagged as JS eval()");
456+
457+
// A genuine eval() call must still be flagged.
458+
let real = write_test_file(&dir, "real.js", "const r = eval(userInput);\n")?;
459+
let report2 = assail::analyze(&real)?;
460+
let flagged2 = report2
461+
.weak_points
462+
.iter()
463+
.any(|wp| matches!(wp.category, WeakPointCategory::DynamicCodeExecution));
464+
assert!(flagged2, "genuine JS eval() must still be flagged");
465+
Ok(())
466+
}

0 commit comments

Comments
 (0)