Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions src/engine/context/extraction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,14 +274,15 @@ pub fn extract_symbols_from_diff(
continue;
}

// Early cutoff: stop processing lines for this file once cap is reached
if file_symbol_count >= MAX_SYMBOLS_PER_FILE {
break;
}

let language = &file.language;
let symbols = extract_symbols_from_line(&line.content, language);

for sym in symbols {
if file_symbol_count >= MAX_SYMBOLS_PER_FILE {
break;
}

let key = format!("{sym}");
if seen.insert((key, file_path.to_string())) {
all_symbols.push(ExtractedSymbol {
Expand All @@ -291,8 +292,17 @@ pub fn extract_symbols_from_diff(
raw: line.content.clone(),
});
file_symbol_count += 1;
if file_symbol_count >= MAX_SYMBOLS_PER_FILE {
break;
}
}
}
if file_symbol_count >= MAX_SYMBOLS_PER_FILE {
break;
}
}
if file_symbol_count >= MAX_SYMBOLS_PER_FILE {
break;
}
}

Expand Down
80 changes: 63 additions & 17 deletions src/engine/context/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ fn resolve_symbols(
let mut entries = Vec::new();
let mut seen_files: HashMap<String, Vec<(u32, u32)>> = HashMap::new(); // track line ranges per file

// File content cache to avoid re-reading the same files from disk
let mut file_cache: HashMap<std::path::PathBuf, String> = HashMap::new();

for sym in symbols {
// Determine the language from the file extension
let lang = Path::new(&sym.file)
Expand All @@ -154,8 +157,12 @@ fn resolve_symbols(

let resolved = match &sym.kind {
SymbolKind::Import(path) => resolve_import(path, &sym.file, lang, project_root),
SymbolKind::FunctionCall(name) => resolve_function(name, &sym.file, lang, project_root),
SymbolKind::TypeRef(name) => resolve_type(name, &sym.file, lang, project_root),
SymbolKind::FunctionCall(name) => {
resolve_function(name, &sym.file, lang, project_root, &mut file_cache)
}
SymbolKind::TypeRef(name) => {
resolve_type(name, &sym.file, lang, project_root, &mut file_cache)
}
};

for entry in resolved {
Expand Down Expand Up @@ -344,6 +351,7 @@ fn resolve_function(
source_file: &str,
lang: &str,
project_root: &Path,
file_cache: &mut HashMap<std::path::PathBuf, String>,
) -> Vec<ContextEntry> {
let mut entries = Vec::new();

Expand All @@ -370,7 +378,9 @@ fn resolve_function(
continue;
}

if let Some((start, end)) = find_fn_in_file(&path, name, "rs") {
if let Some((start, end)) =
find_fn_in_file_cached(&path, name, "rs", file_cache)
{
entries.push(ContextEntry {
file: rel_str,
line_start: start,
Expand All @@ -397,7 +407,9 @@ fn resolve_function(
continue;
}

if let Some((start, end)) = find_fn_in_file(&path, name, "py") {
if let Some((start, end)) =
find_fn_in_file_cached(&path, name, "py", file_cache)
{
entries.push(ContextEntry {
file: rel_str,
line_start: start,
Expand All @@ -421,7 +433,8 @@ fn resolve_function(
continue;
}

if let Some((start, end)) = find_fn_generic(&path, name) {
if let Some((start, end)) = find_fn_generic_cached(&path, name, file_cache)
{
entries.push(ContextEntry {
file: rel_str,
line_start: start,
Expand All @@ -445,6 +458,7 @@ fn resolve_type(
source_file: &str,
lang: &str,
project_root: &Path,
file_cache: &mut HashMap<std::path::PathBuf, String>,
) -> Vec<ContextEntry> {
let search_dir = Path::new(source_file).parent().unwrap_or(Path::new(""));
let mut entries = Vec::new();
Expand All @@ -466,7 +480,8 @@ fn resolve_type(
continue;
}

if let Some((start, end)) = find_pattern_in_file(&path, &pattern) {
if let Some((start, end)) = find_pattern_in_file_cached(&path, &pattern, file_cache)
{
entries.push(ContextEntry {
file: rel_str,
line_start: start,
Expand All @@ -482,37 +497,68 @@ fn resolve_type(
entries
}

/// Find a function definition in a file and return (start_line, end_line).
fn find_fn_in_file(path: &Path, name: &str, lang: &str) -> Option<(u32, u32)> {
let content = std::fs::read_to_string(path).ok()?;
/// Cached version of `find_fn_in_file` — avoids re-reading files from disk.
fn find_fn_in_file_cached(
path: &Path,
name: &str,
lang: &str,
file_cache: &mut HashMap<PathBuf, String>,
) -> Option<(u32, u32)> {
let content = get_file_cached(path, file_cache)?;

let pattern = match lang {
"rs" => format!("fn {name}"),
"py" => format!("def {name}"),
_ => return find_fn_generic(path, name),
};

let max_lines = match lang {
"rs" => MAX_FN_LINES,
"py" => MAX_FN_LINES,
_ => MAX_FN_LINES,
_ => return find_fn_generic_cached(path, name, file_cache),
};

find_pattern_with_body(&content, &pattern, max_lines)
find_pattern_with_body(&content, &pattern, MAX_FN_LINES)
}

/// Generic function search (for languages without specific patterns).
#[allow(dead_code)]
fn find_fn_generic(path: &Path, name: &str) -> Option<(u32, u32)> {
let content = std::fs::read_to_string(path).ok()?;
find_pattern_with_body(&content, &format!("fn {name}"), MAX_FN_LINES)
}

/// Cached version of `find_fn_generic`.
fn find_fn_generic_cached(
path: &Path,
name: &str,
file_cache: &mut HashMap<PathBuf, String>,
) -> Option<(u32, u32)> {
let content = get_file_cached(path, file_cache)?;
find_pattern_with_body(&content, &format!("fn {name}"), MAX_FN_LINES)
}

/// Find a pattern (like `struct Foo`) and determine its extent.
#[allow(dead_code)]
fn find_pattern_in_file(path: &Path, pattern: &str) -> Option<(u32, u32)> {
let content = std::fs::read_to_string(path).ok()?;
find_pattern_with_body(&content, pattern, MAX_TYPE_LINES)
}

/// Cached version of `find_pattern_in_file`.
fn find_pattern_in_file_cached(
path: &Path,
pattern: &str,
file_cache: &mut HashMap<PathBuf, String>,
) -> Option<(u32, u32)> {
let content = get_file_cached(path, file_cache)?;
find_pattern_with_body(&content, pattern, MAX_TYPE_LINES)
}

/// Read a file from disk, using the cache if available.
fn get_file_cached(path: &Path, cache: &mut HashMap<PathBuf, String>) -> Option<String> {
if let Some(content) = cache.get(path) {
return Some(content.clone());
}
let content = std::fs::read_to_string(path).ok()?;
cache.insert(path.to_path_buf(), content.clone());
Some(content)
}

/// Find a pattern in content and estimate the block extent by counting braces/indents.
fn find_pattern_with_body(content: &str, pattern: &str, max_lines: usize) -> Option<(u32, u32)> {
let mut start_line: Option<usize> = None;
Expand Down
12 changes: 12 additions & 0 deletions src/engine/rules/builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub fn builtin_rules() -> Vec<CustomRule> {
message: "Possible hardcoded secret/credential detected. Use environment variables or a secrets manager.".to_string(),
languages: vec!["all".to_string()],
exclude: vec!["rules/".to_string(), "tests/".to_string(), "test/".to_string()],
..Default::default()
},
CustomRule {
id: "sec-sql-concat".to_string(),
Expand All @@ -23,6 +24,7 @@ pub fn builtin_rules() -> Vec<CustomRule> {
message: "Possible SQL injection via string concatenation in query. Use parameterized queries.".to_string(),
languages: vec!["all".to_string()],
exclude: vec!["rules/".to_string(), "tests/".to_string(), "test/".to_string()],
..Default::default()
},
CustomRule {
id: "sec-hardcoded-url".to_string(),
Expand All @@ -32,6 +34,7 @@ pub fn builtin_rules() -> Vec<CustomRule> {
message: "Insecure HTTP URL detected (not https). Use HTTPS for all external connections.".to_string(),
languages: vec!["all".to_string()],
exclude: vec!["rules/".to_string(), "tests/".to_string(), "test/".to_string()],
..Default::default()
},
CustomRule {
id: "sec-tls-disabled".to_string(),
Expand All @@ -40,6 +43,7 @@ pub fn builtin_rules() -> Vec<CustomRule> {
message: "TLS verification disabled. This allows man-in-the-middle attacks.".to_string(),
languages: vec!["rs".to_string(), "py".to_string(), "go".to_string()],
exclude: vec!["rules/".to_string(), "tests/".to_string(), "test/".to_string()],
..Default::default()
},
// --- Bugs ---
CustomRule {
Expand All @@ -49,6 +53,7 @@ pub fn builtin_rules() -> Vec<CustomRule> {
message: "Use of `.unwrap()` can panic in production. Handle the error properly.".to_string(),
languages: vec!["rs".to_string()],
exclude: vec!["tests/".to_string(), "test/".to_string()],
..Default::default()
},
CustomRule {
id: "bug-expect".to_string(),
Expand All @@ -58,6 +63,7 @@ pub fn builtin_rules() -> Vec<CustomRule> {
message: "Use of `.expect()` can panic in production. Consider proper error handling.".to_string(),
languages: vec!["rs".to_string()],
exclude: vec!["tests/".to_string(), "test/".to_string()],
..Default::default()
},
CustomRule {
id: "bug-println".to_string(),
Expand All @@ -66,6 +72,7 @@ pub fn builtin_rules() -> Vec<CustomRule> {
message: "Debug output macro found. Remove `println!`/`dbg!`/`print!` before merging.".to_string(),
languages: vec!["rs".to_string()],
exclude: vec!["tests/".to_string(), "test/".to_string()],
..Default::default()
},
CustomRule {
id: "bug-todo".to_string(),
Expand All @@ -74,6 +81,7 @@ pub fn builtin_rules() -> Vec<CustomRule> {
message: "TODO/FIXME/HACK/XXX comment found. Consider resolving before merge.".to_string(),
languages: vec!["all".to_string()],
exclude: vec![],
..Default::default()
},
CustomRule {
id: "bug-console-log".to_string(),
Expand All @@ -82,6 +90,7 @@ pub fn builtin_rules() -> Vec<CustomRule> {
message: "Console logging statement found. Remove before merging to production.".to_string(),
languages: vec!["js".to_string(), "ts".to_string()],
exclude: vec!["tests/".to_string(), "test/".to_string()],
..Default::default()
},
CustomRule {
id: "bug-hardcoded-port".to_string(),
Expand All @@ -90,6 +99,7 @@ pub fn builtin_rules() -> Vec<CustomRule> {
message: "Hardcoded port number detected. Consider using environment variables or config.".to_string(),
languages: vec!["all".to_string()],
exclude: vec![],
..Default::default()
},
// --- Quality ---
CustomRule {
Expand All @@ -99,6 +109,7 @@ pub fn builtin_rules() -> Vec<CustomRule> {
message: "Error result discarded with `let _ =`. Consider handling the error explicitly.".to_string(),
languages: vec!["all".to_string()],
exclude: vec![],
..Default::default()
},
CustomRule {
id: "qual-clone".to_string(),
Expand All @@ -107,6 +118,7 @@ pub fn builtin_rules() -> Vec<CustomRule> {
message: "Use of `.clone()` detected. Consider borrowing or ownership transfer.".to_string(),
languages: vec!["rs".to_string()],
exclude: vec![],
..Default::default()
},
]
}
Expand Down
15 changes: 9 additions & 6 deletions src/engine/rules/matching.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ pub fn matches_exclude(rule: &CustomRule, file_path: &str) -> bool {

/// Check if a rule's regex pattern matches a line of code.
pub fn match_rule_against_line(rule: &CustomRule, line: &str) -> bool {
match regex::Regex::new(&rule.pattern) {
Ok(re) => re.is_match(line),
Err(e) => {
debug!(rule_id = %rule.id, error = %e, "invalid regex in rule, skipping");
match &rule.compiled_pattern {
Some(re) => re.is_match(line),
None => {
debug!(rule_id = %rule.id, "regex not compiled, skipping");
false
}
}
Expand Down Expand Up @@ -112,14 +112,17 @@ mod tests {
use crate::engine::Severity;

fn make_rule(pattern: &str, languages: &[&str], exclude: &[&str]) -> CustomRule {
CustomRule {
let mut rule = CustomRule {
id: "test-rule".to_string(),
pattern: pattern.to_string(),
severity: Severity::Minor,
message: "test".to_string(),
languages: languages.iter().map(|s| s.to_string()).collect(),
exclude: exclude.iter().map(|s| s.to_string()).collect(),
}
compiled_pattern: None,
};
rule.ensure_compiled();
rule
}

#[test]
Expand Down
5 changes: 5 additions & 0 deletions src/engine/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ pub fn run_rules(chunks: &[FileChunk], config: &RulesConfig) -> Vec<RuleFinding>
let mut all_rules = builtin::builtin_rules();
all_rules.extend(config.custom_rules.clone());

// Compile regex patterns once before the matching loop (Fix #41)
for rule in &mut all_rules {
rule.ensure_compiled();
}

debug!(
rule_count = all_rules.len(),
"running rules against diff chunks"
Expand Down
22 changes: 21 additions & 1 deletion src/engine/rules/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ impl Default for RulesConfig {
}

/// A user-defined or built-in rule definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CustomRule {
/// Unique rule identifier (e.g., `"sec-hardcoded-secret"`).
pub id: String,
Expand All @@ -39,6 +39,26 @@ pub struct CustomRule {
pub languages: Vec<String>,
/// Glob patterns for file paths to exclude from this rule.
pub exclude: Vec<String>,
/// Pre-compiled regex for the pattern (populated after rule assembly).
#[serde(skip, default)]
pub compiled_pattern: Option<regex::Regex>,
}

impl CustomRule {
/// Compile the rule's pattern into a cached regex, if not already compiled.
/// Returns `true` if compilation succeeded, `false` if the pattern is invalid.
pub fn ensure_compiled(&mut self) -> bool {
if self.compiled_pattern.is_some() {
return true;
}
match regex::Regex::new(&self.pattern) {
Ok(re) => {
self.compiled_pattern = Some(re);
true
}
Err(_) => false,
}
}
}

/// A single finding produced by a rule.
Expand Down
Loading
Loading