diff --git a/crates/aegis-core/src/security.rs b/crates/aegis-core/src/security.rs index 40e75f3..839406f 100644 --- a/crates/aegis-core/src/security.rs +++ b/crates/aegis-core/src/security.rs @@ -736,13 +736,39 @@ fn enclosing_token_context(node: Node, src: &[u8]) -> Option<&'static str> { // the random.choices() call (`chars = string.ascii_letters + ...`), // so the assignment text alone misses it. Bounded to small // scopes (<1500 bytes) to keep false-positive rate near zero. - if saw_assignment && matches!( + let is_function_shape = matches!( n.kind(), - "block" | "function_definition" | "function_body" - | "function_declaration" | "method_definition" - | "function_expression" | "arrow_function" - | "lambda_expression" | "function_item" - ) { + "function_definition" | "function_declaration" + | "method_definition" | "method_declaration" + | "function_expression" | "lambda_expression" | "function_item" + | "constructor_declaration" + ); + let is_block_shape = matches!(n.kind(), "block" | "function_body"); + + if saw_assignment && (is_function_shape || is_block_shape) { + // Function-NAME needle check. Round 9 surfaced this: + // Java's `generateSessionToken` calls `new Random().nextInt` + // through a generic loop variable `idx`; the enclosing + // assignment `int idx = ...` has no security-flavoured + // name, but the function name itself contains `session` / + // `token`. Only checked at the function-shape level (block + // alone has no name field). + if is_function_shape { + if let Some(name_node) = n.child_by_field_name("name") { + if let Ok(name_text) = name_node.utf8_text(src) { + let lower = name_text.to_ascii_lowercase(); + for needle in needles { + if lower.contains(needle) { + return Some(needle); + } + } + } + } + } + // Opaque-id heuristic across the bounded body — catches + // the URL-shortener "chars = string.ascii_letters + + // string.digits" pattern even when defined one line + // above the RNG call. if let Ok(text) = n.utf8_text(src) { if text.len() < 1500 { let lower = text.to_ascii_lowercase(); @@ -751,7 +777,16 @@ fn enclosing_token_context(node: Node, src: &[u8]) -> Option<&'static str> { } } } - break; + // Inner blocks (for / if / while bodies) don't terminate + // the upward walk — keep climbing until we either find + // a function-shape node or hit the loop limit. Breaking + // at every block was the bug Round 9 surfaced: it + // stopped at the for-body block before reaching the + // method declaration carrying the security-flavoured + // function name. + if is_function_shape { + break; + } } cur = n.parent(); } @@ -1740,6 +1775,59 @@ mod tests { assert!(v.iter().any(|v| v.rule_id == "SEC010"), "got {v:?}"); } + #[test] + fn sec010_java_new_random_in_session_token_function_blocks() { + // Round 9 production case: `int idx = new Random().nextInt(...)`. + // The local variable `idx` doesn't match a needle, but the + // enclosing function `generateSessionToken` does (contains + // "session" / "token"). Function-name check gates this in. + let v = check( + ".java", + "import java.util.Random;\n\ + public class Auth {\n \ + public static String generateSessionToken() {\n \ + String chars = \"abcdef0123456789\";\n \ + StringBuilder token = new StringBuilder();\n \ + for (int i = 0; i < 16; i++) {\n \ + int idx = new Random().nextInt(chars.length());\n \ + token.append(chars.charAt(idx));\n \ + }\n \ + return token.toString();\n \ + }\n\ + }\n", + ); + assert!(v.iter().any(|v| v.rule_id == "SEC010"), "got {v:?}"); + } + + #[test] + fn sec010_python_random_in_token_function_blocks() { + // Same pattern, Python: function name `make_session_token` + // contains needle `token` even though the inner call site + // uses an unnamed comprehension. + let v = check( + ".py", + "import random\n\ + def make_session_token():\n \ + chars = 'abcdef0123456789'\n \ + idx = random.randint(0, len(chars) - 1)\n \ + return chars[idx]\n", + ); + assert!(v.iter().any(|v| v.rule_id == "SEC010"), "got {v:?}"); + } + + #[test] + fn sec010_random_in_dice_function_does_not_block() { + // Function name has no security needle; opaque-id heuristic + // doesn't fire either. Must stay silent. + let v = check( + ".py", + "import random\n\ + def roll_dice():\n \ + return random.randint(1, 6)\n", + ); + assert!(!v.iter().any(|v| v.rule_id == "SEC010"), "got {v:?}"); + } + #[test] fn sec010_java_new_secure_random_does_not_block() { let v = check(