diff --git a/Cargo.lock b/Cargo.lock index 96f160f..88e9426 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -528,6 +538,19 @@ dependencies = [ "wasip3", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "guard" version = "0.1.0" @@ -536,6 +559,7 @@ dependencies = [ "chrono", "clap", "http", + "ignore", "open", "oxc", "oxc_ast", @@ -702,6 +726,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ignore" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe112b004901c62c2faa11f4f75e9864e0cc5af8da71c9115d184a3aa888749f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.14.0" diff --git a/Cargo.toml b/Cargo.toml index 8c80ebc..0c02d44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ tree-sitter = "0.25" tree-sitter-go = "0.25" streaming-iterator = "0.1.9" rayon = "1.12.0" +ignore = "0.4" diff --git a/README.md b/README.md index ddf3607..6867030 100644 --- a/README.md +++ b/README.md @@ -75,12 +75,18 @@ Guard can export scan results in **SARIF format**, which is compatible with GitH --- +### Generate quick analysis + +```bash +guard analyze path/to/(file or dir) +``` ### Generate SARIF report ```bash -guard scan path/to/file.js --sarif +guard scan path/to/(file or dir) --sarif ``` + ### Note This will: @@ -131,6 +137,8 @@ flutter build web --base-href /app/ ## Planned +- [ ] Resolution of Symbol and Calls +- [ ] Call Graph (cross file referencing) - [ ] Taint Analysis - [ ] Python support - [ ] Java support @@ -141,3 +149,4 @@ flutter build web --base-href /app/ - [x] TypeScript support - [x] Go support - [x] SARIF output format (GitHub Code Scanning compatible) +- [x] SymbolTable and CallTable extraction diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 11fe91e..91f8d56 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,61 +1,57 @@ +use ignore::WalkBuilder; use rayon::prelude::*; +use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; -use walkdir::WalkDir; -use crate::server::models::findings::{severity_order, FinalFindings}; +use crate::server::models::calls::CallSite; use crate::server::service::OWASPScanner; use crate::AppState; - -const IGNORED_DIRS: &[&str] = &[ - "node_modules", - "vendor", - "target", - "__pycache__", - ".venv", - "venv", - ".git", - ".idea", - ".vscode", - "dist", - "build", -]; +use crate::{ + server::models::{ + calls::CallTable, + findings::{severity_order, FinalFindings}, + symbols::{Symbol, SymbolTable}, + }, + state::ScanData, +}; // start the scan of the directory pub async fn scan(path: String, state: Arc>) -> String { let owasp_scanner = OWASPScanner::new(); - - let entries: Vec<_> = WalkDir::new(&path) - .into_iter() + let entries: Vec<_> = WalkBuilder::new(&path) + .hidden(false) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .build() .filter_map(|e| e.ok()) - .filter(|e| { - !e.path().components().any(|c| { - c.as_os_str() - .to_str() - .map(|s| IGNORED_DIRS.contains(&s)) - .unwrap_or(false) // maybe not utf-8, but still try and log error if any - }) - }) .filter(|e| OWASPScanner::determine_language(&e.path().to_string_lossy()).is_some()) .collect(); - // scan in parallel using rayon - let all_findings: Vec = entries + // collect both findings AND symbols AND callsites in parallel + let all_results: Vec<(FinalFindings, Vec, Vec)> = entries .par_iter() .filter_map(|entry| { let path = entry.path(); match std::fs::read_to_string(path) { Ok(content) => { - let mut findings = owasp_scanner.scan(&content, &path.to_string_lossy()); - if findings.is_empty() { + let mut scan_result = owasp_scanner.scan(&content, &path.to_string_lossy()); + if scan_result.findings.is_empty() && scan_result.symbols.is_empty() { return None; } - findings.sort_by_key(|f| severity_order(&f.severity)); - Some(FinalFindings { - file_name: path.to_string_lossy().to_string(), - findings, - }) + scan_result + .findings + .sort_by_key(|f| severity_order(&f.severity)); + Some(( + FinalFindings { + file_name: path.to_string_lossy().to_string(), + findings: scan_result.findings, + }, + scan_result.symbols, + scan_result.calls, + )) } Err(e) => { eprintln!("Error reading file {}: {}", path.display(), e); @@ -65,8 +61,58 @@ pub async fn scan(path: String, state: Arc>) -> String { }) .collect(); + /* after all that it would be like e.g. + all_results = [ + (FinalFindings { file: "express/index.js" }, vec![Symbol, Symbol], vec![CallSite, CallSite]), + (FinalFindings { file: "express/router.js" }, vec![Symbol, Symbol], vec![CallSite, CallSite]), + (FinalFindings { file: "express/utils.js" }, vec![Symbol], vec![CallSite, CallSite]), + ] */ + + // separate them after parallel scan is done + let mut all_findings = Vec::new(); + let mut all_symbols = Vec::new(); + let mut all_calls = Vec::new(); + + for (findings, symbols, calls) in all_results { + all_findings.push(findings); + all_symbols.push(symbols); + all_calls.push(calls); + } + + // flatten symbols into SymbolTable + let mut symbol_map: HashMap> = HashMap::new(); + for symbols in all_symbols { + for symbol in symbols { + symbol_map + .entry(symbol.file.clone()) + .or_insert_with(Vec::new) + .push(symbol); + } + } + + // flatten calls into CallTable + let mut call_map: HashMap> = HashMap::new(); + for calls in all_calls { + for call in calls { + call_map + .entry(call.file.clone()) + .or_insert_with(Vec::new) + .push(call); + } + } + let scan_id = Uuid::new_v4().to_string(); let mut state = state.write().await; - state.results.insert(scan_id.clone(), all_findings); + let symbol_table = SymbolTable { + symbols: symbol_map, + }; + let scan_data = ScanData { + findings: all_findings, + symbol_table, + call_table: CallTable { calls: call_map }, + }; + + state.results.insert(scan_id.clone(), scan_data); + scan_id } diff --git a/src/main.rs b/src/main.rs index 192dd82..4a5d379 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use tokio::sync::RwLock; use crate::server::detectors::shared::sarif::*; -use crate::server::models::findings::Findings; +use crate::server::models::{findings::Findings, symbols::SymbolKind}; use crate::state::AppState; mod cli; @@ -23,6 +23,9 @@ enum Command { output: Option, }, Serve, + Analyze { + path: String, + }, } #[tokio::main] @@ -50,7 +53,7 @@ async fn main() { let state_read = state.read().await; if let Some(results) = state_read.results.get(&scan_id) { - match to_sarif_json(&results) { + match to_sarif_json(&results.findings) { Ok(json) => { std::fs::write(file_path, json).unwrap(); } @@ -70,5 +73,45 @@ async fn main() { } tokio::signal::ctrl_c().await.unwrap(); } + Command::Analyze { path } => { + let scan_id = cli::scan(path, Arc::clone(&state)).await; + let state_read = state.read().await; + + if let Some(scan_data) = state_read.results.get(&scan_id) { + let mut functions = 0; + let mut methods = 0; + let mut variables = 0; + let mut parameters = 0; + let mut imports = 0; + let mut classes = 0; + + for symbols in scan_data.symbol_table.symbols.values() { + for sym in symbols { + match sym.kind { + SymbolKind::Function => functions += 1, + SymbolKind::Method => methods += 1, + SymbolKind::Variable => variables += 1, + SymbolKind::Parameter => parameters += 1, + SymbolKind::Import => imports += 1, + SymbolKind::Class => classes += 1, + _ => {} + } + } + } + + let total = functions + methods + variables + parameters + imports + classes; + + println!("\nAnalysis complete"); + println!("─────────────────────────────"); + println!(" Functions: {}", functions); + println!(" Methods: {}", methods); + println!(" Variables: {}", variables); + println!(" Parameters: {}", parameters); + println!(" Imports: {}", imports); + println!(" Classes: {}", classes); + println!("─────────────────────────────"); + println!(" Total: {}", total); + } + } } } diff --git a/src/server/detectors/golang/exec_injection.rs b/src/server/detectors/golang/exec_injection.rs index 8077ce0..5c3a329 100644 --- a/src/server/detectors/golang/exec_injection.rs +++ b/src/server/detectors/golang/exec_injection.rs @@ -46,23 +46,23 @@ const QUERY_SYSCALL_EXEC: &str = r#" "#; impl GolangTreeSitter<'_> { - pub(super) fn check_exec(&mut self, root: Node, src: &[u8]) { - for m in match_pattern(QUERY_EXEC_COMMAND_DYNAMIC, root, src) { + pub(super) fn check_exec(&mut self, root: Node, code_bytes: &[u8]) { + for m in match_pattern(QUERY_EXEC_COMMAND_DYNAMIC, root, code_bytes) { if let Some((_, snippet, line, _)) = m.iter().find(|(n, ..)| n == "snippet") { self.report( snippet, - &line.to_string(), + root, VulnerabilityType::UnsafeCodeExecution, Severity::High, ); } } - for m in match_pattern(QUERY_EXEC_COMMAND_SHELL, root, src) { + for m in match_pattern(QUERY_EXEC_COMMAND_SHELL, root, code_bytes) { if let Some((_, snippet, line, _)) = m.iter().find(|(n, ..)| n == "snippet") { self.report( snippet, - &line.to_string(), + root, VulnerabilityType::UnsafeCodeExecution, Severity::High, ); @@ -70,15 +70,15 @@ impl GolangTreeSitter<'_> { } } - pub(super) fn check_syscall(&mut self, root: Node, src: &[u8]) { - for m in match_pattern(QUERY_SYSCALL_EXEC, root, src) { + pub(super) fn check_syscall(&mut self, root: Node, code_bytes: &[u8]) { + for m in match_pattern(QUERY_SYSCALL_EXEC, root, code_bytes) { if let (Some((_, snippet, line, _)), Some((_, method, _, _))) = ( m.iter().find(|(n, ..)| n == "snippet"), m.iter().find(|(n, ..)| n == "method"), ) { self.report( snippet, - &line.to_string(), + root, VulnerabilityType::UnsafeCodeExecution, Severity::High, ); diff --git a/src/server/detectors/golang/file_opertaions.rs b/src/server/detectors/golang/file_operations.rs similarity index 88% rename from src/server/detectors/golang/file_opertaions.rs rename to src/server/detectors/golang/file_operations.rs index 7be2319..e150d12 100644 --- a/src/server/detectors/golang/file_opertaions.rs +++ b/src/server/detectors/golang/file_operations.rs @@ -20,15 +20,15 @@ const QUERY_FILE_OS: &str = r#" // For now we keep filepath.Join unreported to avoid false positives. impl GolangTreeSitter<'_> { - pub(super) fn check_file_ops(&mut self, root: Node, src: &[u8]) { - for m in match_pattern(QUERY_FILE_OS, root, src) { + pub(super) fn check_file_ops(&mut self, root: Node, code_bytes: &[u8]) { + for m in match_pattern(QUERY_FILE_OS, root, code_bytes) { if let (Some((_, snippet, line, _)), Some((_, method, _, _))) = ( m.iter().find(|(n, ..)| n == "snippet"), m.iter().find(|(n, ..)| n == "method"), ) { self.report( snippet, - &line.to_string(), + root, VulnerabilityType::UnsafeFileOperation, Severity::High, ); diff --git a/src/server/detectors/golang/go_scanner.rs b/src/server/detectors/golang/go_scanner.rs index 064eba2..edc1cbc 100644 --- a/src/server/detectors/golang/go_scanner.rs +++ b/src/server/detectors/golang/go_scanner.rs @@ -1,18 +1,29 @@ use crate::server::models::findings::{Severity, VulnerabilityType}; -use crate::server::{detectors::Scanner, models::findings::Findings}; +use crate::server::{ + detectors::Scanner, + models::{ + calls::CallSite, + findings::Findings, + results::ScanResult, + symbols::{Symbol, SymbolKind}, + }, +}; pub struct GolangScanner; pub(super) struct GolangTreeSitter<'a> { findings: Vec, + symbols: Vec, file_path: &'a str, + scope_stack: Vec, + calls: Vec, } impl Scanner for GolangScanner { - fn scan(&self, code: &str, file_path: &str) -> Vec { + fn scan(&self, code: &str, file_path: &str) -> ScanResult { let mut tree_sitter = GolangTreeSitter::new(file_path); tree_sitter.analyze(code); - tree_sitter.list_possible_threats() + tree_sitter.into_result() } } @@ -21,6 +32,317 @@ impl<'a> GolangTreeSitter<'a> { Self { findings: Vec::new(), file_path, + symbols: Vec::new(), + scope_stack: Vec::new(), + calls: Vec::new(), + } + } + + fn current_scope(&self) -> String { + if self.scope_stack.is_empty() { + "global".to_string() + } else { + self.scope_stack.join("::") + } + } + + fn extract_symbols(&mut self, root: tree_sitter::Node, code_bytes: &[u8]) { + self.walk_node(root, code_bytes); + } + + fn walk_node(&mut self, node: tree_sitter::Node, code_bytes: &[u8]) { + match node.kind() { + // standalone function + "function_declaration" => { + if let Some(name_node) = node.child_by_field_name("name") { + let name = name_node.utf8_text(code_bytes).unwrap_or("").to_string(); + let line = name_node.start_position().row + 1; + + self.symbols.push(Symbol { + name: name.clone(), + kind: SymbolKind::Function, + file: self.file_path.to_string(), + line, + column: name_node.start_position().column, + scope: self.current_scope(), + }); + + self.scope_stack.push(name); + // extract parameters + if let Some(params) = node.child_by_field_name("parameters") { + self.extract_go_params(params, code_bytes); + } + + self.walk_children(node, code_bytes); + self.scope_stack.pop(); + } + } + + // method on a type + "method_declaration" => { + if let Some(name_node) = node.child_by_field_name("name") { + let name = name_node.utf8_text(code_bytes).unwrap_or("").to_string(); + let line = name_node.start_position().row + 1; + + // get receiver type for scope + let receiver_type = node + .child_by_field_name("receiver") + .and_then(|r| { + // bind to variable so cursor lifetime is valid + let mut cursor = r.walk(); + let param = r + .children(&mut cursor) + .find(|c| c.kind() == "parameter_declaration"); + + param + .and_then(|p| p.child_by_field_name("type")) + .and_then(|t| { + if t.kind() == "pointer_type" { + t.child(1) + .and_then(|inner| inner.utf8_text(code_bytes).ok()) + } else { + t.utf8_text(code_bytes).ok() + } + }) + .map(|s| s.trim_start_matches('*').to_string()) + }) + .unwrap_or("unknown".to_string()); + + self.symbols.push(Symbol { + name: name.clone(), + kind: SymbolKind::Method, + file: self.file_path.to_string(), + line, + column: name_node.start_position().column, + scope: receiver_type.clone(), + }); + + self.scope_stack.push(receiver_type); + self.scope_stack.push(name); + + // extract parameters + if let Some(params) = node.child_by_field_name("parameters") { + self.extract_go_params(params, code_bytes); + } + + self.walk_children(node, code_bytes); + self.scope_stack.pop(); + self.scope_stack.pop(); + } + } + + // short variable declaration: x := something + "short_var_declaration" => { + if let Some(left) = node.child_by_field_name("left") { + let mut cursor = left.walk(); + for child in left.children(&mut cursor) { + if child.kind() == "identifier" { + let name = child.utf8_text(code_bytes).unwrap_or("").to_string(); + self.symbols.push(Symbol { + name, + kind: SymbolKind::Variable, + file: self.file_path.to_string(), + line: child.start_position().row + 1, + column: child.start_position().column, + scope: self.current_scope(), + }); + } + } + } + self.walk_children(node, code_bytes); + } + + // var declaration: var x int = something + "var_declaration" => { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "var_spec" { + if let Some(name_node) = child.child_by_field_name("name") { + let name = name_node.utf8_text(code_bytes).unwrap_or("").to_string(); + self.symbols.push(Symbol { + name, + kind: SymbolKind::Variable, + file: self.file_path.to_string(), + line: name_node.start_position().row + 1, + column: name_node.start_position().column, + scope: self.current_scope(), + }); + } + } + } + self.walk_children(node, code_bytes); + } + + "import_declaration" => { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + match child.kind() { + // single import + // import import_1 + "import_spec" => { + self.collect_import_spec(child, code_bytes); + } + // grouped imports + /* + import( + import_1 + import_2 + ) */ + "import_spec_list" => { + let mut list_cursor = child.walk(); + for spec in child.children(&mut list_cursor) { + if spec.kind() == "import_spec" { + self.collect_import_spec(spec, code_bytes); + } + } + } + _ => {} + } + } + } + + // struct types + "type_declaration" => { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "type_spec" { + let has_struct = child + .child_by_field_name("type") + .map(|t| t.kind() == "struct_type") + .unwrap_or(false); + + if has_struct { + if let Some(name_node) = child.child_by_field_name("name") { + let name = + name_node.utf8_text(code_bytes).unwrap_or("").to_string(); + self.symbols.push(Symbol { + name, + kind: SymbolKind::Struct, + file: self.file_path.to_string(), + line: name_node.start_position().row + 1, + column: name_node.start_position().column, + scope: "global".to_string(), + }); + } + } + } + } + } + + "call_expression" => { + let function_node = node.child_by_field_name("function"); + let args_node = node.child_by_field_name("arguments"); + + // extract arguments + let arguments: Vec = args_node + .map(|args| { + let mut cursor = args.walk(); + args.children(&mut cursor) + // strip away ',' and parantheses! + .filter(|c| c.kind() != "," && c.kind() != "(" && c.kind() != ")") + .map(|c| c.utf8_text(code_bytes).unwrap_or("").to_string()) + .collect() + }) + .unwrap_or_default(); + + if let Some(func) = function_node { + match func.kind() { + // plain call: save(x) + "identifier" => { + let callee = func.utf8_text(code_bytes).unwrap_or("").to_string(); + self.calls.push(CallSite { + callee, + object: None, + arguments, + caller: self.current_scope(), + file: self.file_path.to_string(), + line: func.start_position().row + 1, + column: func.start_position().column, + }); + } + + // method call: db.Query(x) + "selector_expression" => { + let callee = func + .child_by_field_name("field") + .and_then(|f| f.utf8_text(code_bytes).ok()) + .unwrap_or("") + .to_string(); + + let object = func.child_by_field_name("operand").map(|o| { + // if operand is itself a selector (r.db), take the field + // if operand is plain identifier (db), take it directly + if o.kind() == "selector_expression" { + o.child_by_field_name("field") + .and_then(|f| f.utf8_text(code_bytes).ok()) + .unwrap_or("") + .to_string() + } else { + o.utf8_text(code_bytes).unwrap_or("").to_string() + } + }); + + self.calls.push(CallSite { + callee, + object, + arguments, + caller: self.current_scope(), + file: self.file_path.to_string(), + line: func.start_position().row + 1, + column: func.start_position().column, + }); + } + + _ => {} + } + } + } + + // everything else — just walk children + _ => { + self.walk_children(node, code_bytes); + } + } + } + + fn walk_children(&mut self, node: tree_sitter::Node, code_bytes: &[u8]) { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + self.walk_node(child, code_bytes); + } + } + + fn extract_go_params(&mut self, params_node: tree_sitter::Node, code_bytes: &[u8]) { + let mut cursor = params_node.walk(); + for child in params_node.children(&mut cursor) { + if child.kind() == "parameter_declaration" { + if let Some(name_node) = child.child_by_field_name("name") { + let name = name_node.utf8_text(code_bytes).unwrap_or("").to_string(); + self.symbols.push(Symbol { + name, + kind: SymbolKind::Parameter, + file: self.file_path.to_string(), + line: name_node.start_position().row + 1, + column: name_node.start_position().column, + scope: self.current_scope(), + }); + } + } + } + } + + fn collect_import_spec(&mut self, node: tree_sitter::Node, code: &[u8]) { + if let Some(path_node) = node.child_by_field_name("path") { + let raw = path_node.utf8_text(code).unwrap_or("\"\""); + let name = raw.trim_matches('"').to_string(); + self.symbols.push(Symbol { + name, + kind: SymbolKind::Import, + file: self.file_path.to_string(), + line: path_node.start_position().row + 1, + column: path_node.start_position().column, + scope: "global".to_string(), + }); } } @@ -43,32 +365,35 @@ impl<'a> GolangTreeSitter<'a> { } }; let root = tree.root_node(); - let bytes = code.as_bytes(); + let code_bytes = code.as_bytes(); + self.extract_symbols(root, code_bytes); // check for hardcoded secrets - self.check_secrets(root, bytes); + self.check_secrets(root, code_bytes); // check for SQL injection vulnerabilities - self.check_sql_sprintf(root, bytes); - self.check_sql_db(root, bytes); - self.check_gorm(root, bytes); + self.check_sql_sprintf(root, code_bytes); + self.check_sql_db(root, code_bytes); + self.check_gorm(root, code_bytes); // check for command injection vulnerabilities - self.check_exec(root, bytes); - self.check_syscall(root, bytes); + self.check_exec(root, code_bytes); + self.check_syscall(root, code_bytes); // check for unsafe file operations - self.check_file_ops(root, bytes); + self.check_file_ops(root, code_bytes); } pub(super) fn report( &mut self, snippet: &str, - line: &str, + node: tree_sitter::Node, vuln_type: VulnerabilityType, severity: Severity, ) { - // avoid duplicate findings for the same vuln type, line and snippet - let exists = self - .findings - .iter() - .any(|f| f.vuln_type == vuln_type && f.line_no == line && f.snippet == snippet); + let line = (node.start_position().row + 1).to_string(); + let col = node.start_position().column.to_string(); + + // avoid duplicate findings for the same vuln type, line,column and snippet + let exists = self.findings.iter().any(|f| { + f.vuln_type == vuln_type && f.line_no == line && f.col_no == col && f.snippet == snippet + }); if exists { return; @@ -77,12 +402,21 @@ impl<'a> GolangTreeSitter<'a> { self.findings.push(Findings { vuln_type, line_no: line.to_string(), + col_no: col.to_string(), file_path: self.file_path.to_string(), snippet: snippet.to_string(), severity, }); } + pub fn into_result(self) -> ScanResult { + ScanResult { + findings: self.findings, + symbols: self.symbols, + calls: self.calls, + } + } + pub fn list_possible_threats(self) -> Vec { self.findings } diff --git a/src/server/detectors/golang/hardcoded_secrets.rs b/src/server/detectors/golang/hardcoded_secrets.rs index fcdcc78..a75c48d 100644 --- a/src/server/detectors/golang/hardcoded_secrets.rs +++ b/src/server/detectors/golang/hardcoded_secrets.rs @@ -26,13 +26,13 @@ const QUERY_SECRET_STRUCT: &str = r#" "#; impl GolangTreeSitter<'_> { - pub(super) fn check_secrets(&mut self, root: Node, src: &[u8]) { + pub(super) fn check_secrets(&mut self, root: Node, code_bytes: &[u8]) { for query_src in [QUERY_SECRET_VAR, QUERY_SECRET_CONST, QUERY_SECRET_STRUCT] { - for m in match_pattern(query_src, root, src) { + for m in match_pattern(query_src, root, code_bytes) { if let Some((_, snippet, line, _)) = m.iter().find(|(n, ..)| n == "snippet") { self.report( snippet, - &line.to_string(), + root, VulnerabilityType::HardcodedSecret, Severity::Critical, ); diff --git a/src/server/detectors/golang/mod.rs b/src/server/detectors/golang/mod.rs index b93e73d..b136aa3 100644 --- a/src/server/detectors/golang/mod.rs +++ b/src/server/detectors/golang/mod.rs @@ -1,5 +1,5 @@ mod exec_injection; -mod file_opertaions; +mod file_operations; pub mod go_scanner; mod hardcoded_secrets; mod sql_injection; diff --git a/src/server/detectors/golang/sql_injection.rs b/src/server/detectors/golang/sql_injection.rs index 6f8d6ad..87e4750 100644 --- a/src/server/detectors/golang/sql_injection.rs +++ b/src/server/detectors/golang/sql_injection.rs @@ -122,8 +122,8 @@ const QUERY_GORM_RAW_VAR: &str = r#" "#; impl GolangTreeSitter<'_> { - pub(super) fn check_sql_sprintf(&mut self, root: Node, src: &[u8]) { - for m in match_pattern(QUERY_SQL_SPRINTF, root, src) { + pub(super) fn check_sql_sprintf(&mut self, root: Node, code_bytes: &[u8]) { + for m in match_pattern(QUERY_SQL_SPRINTF, root, code_bytes) { let query = m .iter() .find(|(n, ..)| n == "query") @@ -144,7 +144,7 @@ impl GolangTreeSitter<'_> { { self.report( snippet, - &(line.to_string()), + root, VulnerabilityType::SQLInjection, Severity::Critical, ); @@ -152,7 +152,7 @@ impl GolangTreeSitter<'_> { } } - for m in match_pattern(QUERY_SQL_SPRINTF_CONTEXT, root, src) { + for m in match_pattern(QUERY_SQL_SPRINTF_CONTEXT, root, code_bytes) { let query = m .iter() .find(|(n, ..)| n == "query") @@ -173,7 +173,7 @@ impl GolangTreeSitter<'_> { { self.report( snippet, - &(line.to_string()), + root, VulnerabilityType::SQLInjection, Severity::Critical, ); @@ -182,8 +182,8 @@ impl GolangTreeSitter<'_> { } } - pub(super) fn check_sql_db(&mut self, root: Node, src: &[u8]) { - for m in match_pattern(QUERY_SQL_DB, root, src) { + pub(super) fn check_sql_db(&mut self, root: Node, code_bytes: &[u8]) { + for m in match_pattern(QUERY_SQL_DB, root, code_bytes) { let method = m .iter() .find(|(n, ..)| n == "method") @@ -197,14 +197,14 @@ impl GolangTreeSitter<'_> { if let (Some(method), Some((snippet, line, col))) = (method, snippet) { self.report( snippet, - &(line.to_string()), + root, VulnerabilityType::SQLInjection, Severity::Critical, ); } } - for m in match_pattern(QUERY_SQL_DB_CONTEXT, root, src) { + for m in match_pattern(QUERY_SQL_DB_CONTEXT, root, code_bytes) { let method = m .iter() .find(|(n, ..)| n == "method") @@ -218,7 +218,7 @@ impl GolangTreeSitter<'_> { if let (Some(method), Some((snippet, line, col))) = (method, snippet) { self.report( snippet, - &(line.to_string()), + root, VulnerabilityType::SQLInjection, Severity::Critical, ); @@ -228,13 +228,13 @@ impl GolangTreeSitter<'_> { // ── GORM ─── - pub(super) fn check_gorm(&mut self, root: Node, src: &[u8]) { + pub(super) fn check_gorm(&mut self, root: Node, code_bytes: &[u8]) { // string concat: db.Where("id = " + val) - for m in match_pattern(QUERY_GORM_CONCAT, root, src) { + for m in match_pattern(QUERY_GORM_CONCAT, root, code_bytes) { if let Some((_, snippet, line, _)) = m.iter().find(|(n, ..)| n == "snippet") { self.report( snippet, - &(line.to_string()), + root, VulnerabilityType::SQLInjection, Severity::Critical, ); @@ -242,11 +242,11 @@ impl GolangTreeSitter<'_> { } // Sprintf inside Where: db.Where(fmt.Sprintf(...)) - for m in match_pattern(QUERY_GORM_SPRINTF, root, src) { + for m in match_pattern(QUERY_GORM_SPRINTF, root, code_bytes) { if let Some((_, snippet, line, _)) = m.iter().find(|(n, ..)| n == "snippet") { self.report( snippet, - &(line.to_string()), + root, VulnerabilityType::SQLInjection, Severity::Critical, ); @@ -254,11 +254,11 @@ impl GolangTreeSitter<'_> { } // raw variable: db.Raw(query) - for m in match_pattern(QUERY_GORM_RAW_VAR, root, src) { + for m in match_pattern(QUERY_GORM_RAW_VAR, root, code_bytes) { if let Some((_, snippet, line, _)) = m.iter().find(|(n, ..)| n == "snippet") { self.report( snippet, - &(line.to_string()), + root, VulnerabilityType::SQLInjection, Severity::Critical, ); diff --git a/src/server/detectors/golang/tests.rs b/src/server/detectors/golang/tests.rs index ccd2b11..d9765f5 100644 --- a/src/server/detectors/golang/tests.rs +++ b/src/server/detectors/golang/tests.rs @@ -23,8 +23,9 @@ mod tests { } "#; - let findings = SCANNER.scan(code, "test.go"); - let ops: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.go"); + let ops: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::UnsafeFileOperation) .collect(); @@ -42,8 +43,9 @@ mod tests { var token = "tokval" "#; - let findings = SCANNER.scan(code, "test.go"); - let secrets: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.go"); + let secrets: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::HardcodedSecret) .collect(); @@ -66,9 +68,10 @@ mod tests { } "#; - let findings = SCANNER.scan(code, "test.go"); + let scan_result = SCANNER.scan(code, "test.go"); - assert!(findings + assert!(scan_result + .findings .iter() .any(|f| f.vuln_type == VulnerabilityType::SQLInjection)); } @@ -96,9 +99,10 @@ mod tests { } "#; - let findings = SCANNER.scan(code, "test.go"); + let scan_result = SCANNER.scan(code, "test.go"); - assert!(findings + assert!(scan_result + .findings .iter() .any(|f| f.vuln_type == VulnerabilityType::SQLInjection)); } @@ -124,9 +128,10 @@ mod tests { } "#; - let findings = SCANNER.scan(code, "test.go"); + let scan_result = SCANNER.scan(code, "test.go"); - assert!(findings + assert!(scan_result + .findings .iter() .any(|f| f.vuln_type == VulnerabilityType::SQLInjection)); } @@ -144,8 +149,9 @@ mod tests { } "#; - let findings = SCANNER.scan(code, "test.go"); - assert!(findings + let scan_result = SCANNER.scan(code, "test.go"); + assert!(scan_result + .findings .iter() .any(|f| f.vuln_type == VulnerabilityType::UnsafeCodeExecution)); } @@ -163,8 +169,8 @@ mod tests { } "#; - let findings = SCANNER.scan(code, "test.go"); - assert_eq!(findings.len(), 0); + let scan_result = SCANNER.scan(code, "test.go"); + assert_eq!(scan_result.findings.len(), 0); } #[test] @@ -202,7 +208,7 @@ mod tests { } "#; - let findings = SCANNER.scan(code, "test.go"); - assert!(findings.is_empty()); + let scan_result = SCANNER.scan(code, "test.go"); + assert!(scan_result.findings.is_empty()); } } diff --git a/src/server/detectors/javascript/js_scanner.rs b/src/server/detectors/javascript/js_scanner.rs index c32e53d..5e92279 100644 --- a/src/server/detectors/javascript/js_scanner.rs +++ b/src/server/detectors/javascript/js_scanner.rs @@ -1,6 +1,6 @@ use crate::server::detectors::shared::common_parser::{parse_to_ast, CodeVisitor}; use crate::server::detectors::Scanner; -use crate::Findings; +use crate::server::models::results::ScanResult; use oxc::allocator::Allocator; use oxc::ast_visit::Visit; @@ -10,24 +10,24 @@ pub struct JavaScriptScanner; // hardcoded secrets // SQL Injection vulnerabilities impl Scanner for JavaScriptScanner { - fn scan(&self, code: &str, file_path: &str) -> Vec { + fn scan(&self, code: &str, file_path: &str) -> ScanResult { let allocator = Allocator::default(); let ast = match parse_to_ast(code, &allocator, file_path) { Ok(program) => program, Err(e) => { eprintln!("Parse error: {}", e); - return vec![]; + return ScanResult { + findings: vec![], + symbols: vec![], + calls: vec![], + }; } }; - let mut visitor = CodeVisitor { - findings: Vec::new(), - file_path, - source_text: code, - }; + let mut visitor = CodeVisitor::new(file_path, code); visitor.visit_program(&ast); - visitor.list_possible_threats() + visitor.into_scan_result() } } diff --git a/src/server/detectors/javascript/tests.rs b/src/server/detectors/javascript/tests.rs index ef11b7f..019100f 100644 --- a/src/server/detectors/javascript/tests.rs +++ b/src/server/detectors/javascript/tests.rs @@ -20,8 +20,9 @@ mod tests { password: "objsecret" } "#; - let findings = SCANNER.scan(code, "test.js"); - let secrets: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let secrets: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::HardcodedSecret) .collect(); @@ -36,8 +37,9 @@ mod tests { const apiKey = fetchApiKey(); const token = generateToken(); "#; - let findings = SCANNER.scan(code, "test.js"); - let secrets: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let secrets: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::HardcodedSecret) .collect(); @@ -51,8 +53,9 @@ mod tests { const passwd = `${getPassword()}`; const token = `Bearer ${fetchToken()}`; "#; - let findings = SCANNER.scan(code, "test.js"); - let secrets: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let secrets: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::HardcodedSecret) .collect(); @@ -67,8 +70,9 @@ mod tests { const username = "admin"; const greeting = "hello world"; "#; - let findings = SCANNER.scan(code, "test.js"); - let secrets: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let secrets: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::HardcodedSecret) .collect(); @@ -81,8 +85,9 @@ mod tests { const password="supersecret"; const config = { password: "secret" } "#; - let findings = SCANNER.scan(code, "test.js"); - let secrets: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let secrets: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::HardcodedSecret) .collect(); @@ -98,8 +103,9 @@ mod tests { let code = r#" eval("alert(1)"); "#; - let findings = SCANNER.scan(code, "test.js"); - let evals: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let evals: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::Eval) .collect(); @@ -112,8 +118,9 @@ mod tests { let code = r#" const fn = new Function("return 1"); "#; - let findings = SCANNER.scan(code, "test.js"); - let evals: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let evals: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::Eval) .collect(); @@ -127,8 +134,9 @@ mod tests { setTimeout("doSomething()", 1000); setInterval("doSomethingElse()", 500); "#; - let findings = SCANNER.scan(code, "test.js"); - let evals: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let evals: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::Eval) .collect(); @@ -142,8 +150,9 @@ mod tests { Math.random(); parseInt("42"); "#; - let findings = SCANNER.scan(code, "test.js"); - let evals: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let evals: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::Eval) .collect(); @@ -159,8 +168,9 @@ mod tests { let code = r#" db.query(`SELECT * FROM ${table}`); "#; - let findings = SCANNER.scan(code, "test.js"); - let sql: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let sql: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::SQLInjection) .collect(); @@ -172,8 +182,9 @@ mod tests { let code = r#" db.query("SELECT * FROM " + table + " WHERE id = " + id); "#; - let findings = SCANNER.scan(code, "test.js"); - let sql: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let sql: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::SQLInjection) .collect(); @@ -186,8 +197,9 @@ mod tests { let code = r#" db.query("SELECT * FROM users WHERE id = 1"); "#; - let findings = SCANNER.scan(code, "test.js"); - let sql: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let sql: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::SQLInjection) .collect(); @@ -200,8 +212,9 @@ mod tests { let code = r#" db.query(`${"SELECT * FROM " + table}`); "#; - let findings = SCANNER.scan(code, "test.js"); - let sql: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let sql: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::SQLInjection) .collect(); @@ -215,8 +228,9 @@ mod tests { db.query("SELECT * FROM users WHERE id = ?", [id]); db.query("SELECT * FROM users WHERE id = $1", [id]); "#; - let findings = SCANNER.scan(code, "test.js"); - let sql: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let sql: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::SQLInjection) .collect(); @@ -230,8 +244,9 @@ mod tests { const msg = `Hello ${name}`; console.log(`User ${id} logged in`); "#; - let findings = SCANNER.scan(code, "test.js"); - let sql: Vec<_> = findings + let scan_result = SCANNER.scan(code, "test.js"); + let sql: Vec<_> = scan_result + .findings .iter() .filter(|f| f.vuln_type == VulnerabilityType::SQLInjection) .collect(); @@ -249,14 +264,17 @@ mod tests { eval(userInput); db.query(`SELECT * FROM ${table}`); "#; - let findings = SCANNER.scan(code, "test.js"); - assert!(findings + let scan_result = SCANNER.scan(code, "test.js"); + assert!(scan_result + .findings .iter() .any(|f| f.vuln_type == VulnerabilityType::HardcodedSecret)); - assert!(findings + assert!(scan_result + .findings .iter() .any(|f| f.vuln_type == VulnerabilityType::Eval)); - assert!(findings + assert!(scan_result + .findings .iter() .any(|f| f.vuln_type == VulnerabilityType::SQLInjection)); } @@ -268,7 +286,7 @@ mod tests { const version = getVersion(); const result = db.query("SELECT * FROM users WHERE id = ?", [userId]); "#; - let findings = SCANNER.scan(code, "test.js"); - assert_eq!(findings.len(), 0); + let scan_result = SCANNER.scan(code, "test.js"); + assert_eq!(scan_result.findings.len(), 0); } } diff --git a/src/server/detectors/mod.rs b/src/server/detectors/mod.rs index b99eec2..ad957ef 100644 --- a/src/server/detectors/mod.rs +++ b/src/server/detectors/mod.rs @@ -2,10 +2,11 @@ mod golang; mod javascript; pub mod shared; mod typescript; +use crate::server::models::results::ScanResult; pub use golang::go_scanner::GolangScanner; pub use javascript::js_scanner::JavaScriptScanner; pub use typescript::ts_scanner::TypeScriptScanner; pub trait Scanner: Send + Sync { - fn scan(&self, code: &str, file_path: &str) -> Vec; + fn scan(&self, code: &str, file_path: &str) -> ScanResult; } diff --git a/src/server/detectors/shared/common_parser.rs b/src/server/detectors/shared/common_parser.rs index 0803e2d..280b2e4 100644 --- a/src/server/detectors/shared/common_parser.rs +++ b/src/server/detectors/shared/common_parser.rs @@ -1,8 +1,13 @@ use super::js_and_ts::utils::*; -use crate::server::models::findings::{Severity, VulnerabilityType}; +use crate::server::models::{ + calls::CallSite, + findings::{Severity, VulnerabilityType}, + results::ScanResult, + symbols::{Symbol, SymbolKind}, +}; use crate::Findings; use oxc::ast_visit::Visit; -use oxc::span::GetSpan; +use oxc::span::{GetSpan, Span}; use oxc::{allocator::Allocator, parser::Parser, span::SourceType}; use oxc_ast::ast::{BindingPattern, CallExpression, Expression, NewExpression, VariableDeclarator}; use oxc_ast::ast::{TSAsExpression, TSType}; @@ -11,18 +16,112 @@ pub struct CodeVisitor<'a> { pub findings: Vec, pub file_path: &'a str, pub source_text: &'a str, + pub symbols: Vec, + pub calls: Vec, + scope_stack: Vec, + line_starts: Vec, +} + +impl<'a> CodeVisitor<'a> { + pub fn new(file_path: &'a str, source_text: &'a str) -> Self { + // feault start for all files + let mut line_starts = vec![0]; + + // loop through entire file bytes for start + + for (i, c) in source_text.char_indices() { + if c == '\n' { + line_starts.push(i + 1); + } + } + + Self { + file_path, + source_text, + findings: vec![], + symbols: vec![], + scope_stack: vec![], + calls: vec![], + line_starts, + } + } + + // get scope of element, i.e. wher they were declared + fn current_scope(&self) -> String { + if self.scope_stack.is_empty() { + "global".to_string() + } else { + self.scope_stack.join("::") + } + } + + fn span_to_line(&self, span_start: usize) -> usize { + let safe = span_start.min(self.source_text.len()); + self.source_text[..safe].lines().count() + 1 + } + + fn offset_to_line_col(&self, pos: usize) -> (usize, usize) { + let idx = self.line_starts.partition_point(|&x| x <= pos) - 1; + + let line = idx + 1; + let col = pos - self.line_starts[idx]; + + (line, col) + } + + // add to findings + fn report( + &mut self, + snippet: &str, + span: Span, + vuln_type: VulnerabilityType, + severity: Severity, + ) { + let (line, col) = self.offset_to_line_col(span.start as usize); + + self.findings.push(Findings { + vuln_type, + line_no: line.to_string(), + col_no: col.to_string(), + file_path: self.file_path.to_string(), + snippet: snippet.to_string(), + severity, + }); + } + + pub fn list_possible_threats(self) -> Vec { + self.findings + } + + pub fn into_scan_result(self) -> ScanResult { + ScanResult { + findings: self.findings, + symbols: self.symbols, + calls: self.calls, + } + } } // visitor implementation for AST traversal impl<'a> Visit<'a> for CodeVisitor<'a> { + // let var a=1; fn visit_variable_declarator(&mut self, node: &VariableDeclarator<'a>) { // Get variable name from AST first if let BindingPattern::BindingIdentifier(ident) = &node.id { - // unwrap `satisfies` / `as` / `!` before checking the shape + self.symbols.push(Symbol { + name: ident.name.to_string(), + kind: SymbolKind::Variable, + file: self.file_path.to_string(), + line: self.span_to_line(node.span.start as usize), + column: node.span.start as usize, + scope: self.current_scope(), + }); + + // unwrap `satisfies` or `as` or `!` assertions before checking the shape let init = node.init.as_ref().map(unwrap_ts_expression); - // check for Object Pattern like const config = {password: "secret"} + // check for Object Pattern like const { password }= config; if let Some(Expression::ObjectExpression(obj_lit)) = init { - // iterate through the objects + // iterate through properties of object for prop in &obj_lit.properties { if let oxc_ast::ast::ObjectPropertyKind::ObjectProperty(prop) = prop { if let oxc_ast::ast::PropertyKey::StaticIdentifier(ident) = &prop.key { @@ -33,7 +132,7 @@ impl<'a> Visit<'a> for CodeVisitor<'a> { if let Some(value) = is_hardcoded_secret(value) { self.report( &value, - prop.span().start as usize, + prop.span(), VulnerabilityType::HardcodedSecret, Severity::Critical, ); @@ -53,7 +152,7 @@ impl<'a> Visit<'a> for CodeVisitor<'a> { if let Some(value) = is_hardcoded_secret(init) { self.report( &value, - init.span().start as usize, + init.span(), VulnerabilityType::HardcodedSecret, Severity::Critical, ); @@ -62,43 +161,106 @@ impl<'a> Visit<'a> for CodeVisitor<'a> { } } } - // recurse into child + // recurse into child, i.e deep traversal oxc::ast_visit::walk::walk_variable_declarator(self, node); } + // e.g. new Function(...) fn visit_new_expression(&mut self, node: &NewExpression<'a>) { if let Expression::Identifier(ident) = &node.callee { if ident.name.as_str() == "Function" { let name = ident.name.as_str(); - self.report( - name, - node.span().start as usize, - VulnerabilityType::Eval, - Severity::High, - ); + self.report(name, node.span(), VulnerabilityType::Eval, Severity::High); } } // walk into children and recurse oxc::ast_visit::walk::walk_new_expression(self, node); // visits callee + all arguments } - // for fucntions + // for functions + // eg: save(x) + // db.query() + // require() fn visit_call_expression(&mut self, node: &CallExpression<'a>) { // for TS - // unwrap (eval as any)(...) → eval(...) — do this FIRST, unconditionally + // unwrap (eval as any)(...) to eval(...) do this FIRST, unconditionally let callee = unwrap_ts_expression(&node.callee); - if let Expression::Identifier(ident) = callee { - let name = ident.name.as_str(); + let arguments: Vec = node + .arguments + .iter() + .filter_map(|arg| arg.as_expression()) + .map(|expr| { + let start = expr.span().start as usize; + let end = expr.span().end as usize; + self.source_text[start..end].to_string() + }) + .collect(); - if is_dangerous_call(name) { - self.report( - name, - node.span().start as usize, - VulnerabilityType::Eval, - Severity::High, - ); + let (line, col) = self.offset_to_line_col(node.span().start as usize); + + match callee { + /* + save(y) + call(a) + */ + Expression::Identifier(ident) => { + let name = ident.name.as_str(); + + if is_dangerous_call(name) { + self.report(name, node.span(), VulnerabilityType::Eval, Severity::High); + } + + // CommonJS imports via require() + // e.g. module.exports = require('./lib/express'); + // var debug = require('debug'); + if name == "require" { + if let Some(first_arg) = node.arguments.first() { + if let Some(expr) = first_arg.as_expression() { + if let Expression::StringLiteral(lit) = expr { + self.symbols.push(Symbol { + name: lit.value.to_string(), + kind: SymbolKind::Import, + file: self.file_path.to_string(), + line: self.span_to_line(node.span.start as usize), + column: node.span.start as usize, + scope: self.current_scope(), + }); + } + } + } + } + + self.calls.push(CallSite { + callee: name.to_string(), + object: None, + arguments, + caller: self.current_scope(), + file: self.file_path.to_string(), + line, + column: col, + }); } + + // method call: db.query(x), fs.readFile(x) + Expression::StaticMemberExpression(member) => { + let callee_name = member.property.name.to_string(); + let object_name = match &member.object { + Expression::Identifier(id) => Some(id.name.to_string()), + _ => None, + }; + + self.calls.push(CallSite { + callee: callee_name, + object: object_name, + arguments, + caller: self.current_scope(), + file: self.file_path.to_string(), + line, + column: col, + }); + } + _ => {} } // sql injection check @@ -116,13 +278,13 @@ impl<'a> Visit<'a> for CodeVisitor<'a> { let snippet = &self.source_text[start..end]; self.report( snippet, - node.span().start as usize, + node.span(), VulnerabilityType::SQLInjection, Severity::Critical, ); } } - // flag if any part is dynamic OR if it's all static strings forming SQL + // flag if concatenated SQl with no params Expression::BinaryExpression(_) => { let has_params = node.arguments.len() > 1; if !has_params { @@ -131,7 +293,7 @@ impl<'a> Visit<'a> for CodeVisitor<'a> { let snippet = &self.source_text[start..end]; self.report( snippet, - node.span().start as usize, + node.span(), VulnerabilityType::SQLInjection, Severity::Critical, ); @@ -146,45 +308,162 @@ impl<'a> Visit<'a> for CodeVisitor<'a> { oxc::ast_visit::walk::walk_call_expression(self, node); } + // let a as int? fn visit_ts_as_expression(&mut self, node: &TSAsExpression<'a>) { if let TSType::TSAnyKeyword(_) = &node.type_annotation { self.report( "as any", - node.span().start as usize, + node.span(), VulnerabilityType::UnsafeTypeAssertion, Severity::Low, ); } - // manual call to dleev deeper ! - // because we manully need o walk over transparent wrappers! + // manual call to delve deeper ! + // because we manully need to walk over transparent wrappers! // continue traversal into the inner expression oxc::ast_visit::walk::walk_ts_as_expression(self, node); } -} -impl CodeVisitor<'_> { - // add to findings - fn report( + // visit every function + fn visit_function( &mut self, - snippet: &str, - span_start: usize, - vuln_type: VulnerabilityType, - severity: Severity, + node: &oxc_ast::ast::Function<'a>, + flags: oxc::syntax::scope::ScopeFlags, ) { - let safe = span_start.min(self.source_text.len()); - let line = self.source_text[..safe].lines().count() + 1; + if let Some(id) = &node.id { + let name = id.name.to_string(); + let scope = self.current_scope(); - self.findings.push(Findings { - vuln_type, - line_no: line.to_string(), - file_path: self.file_path.to_string(), - snippet: snippet.to_string(), - severity, - }); + let kind = match scope.as_str() { + "global" => SymbolKind::Function, + _ => SymbolKind::Method, + }; + + self.symbols.push(Symbol { + name: name.clone(), + kind, + file: self.file_path.to_string(), + line: self.span_to_line(node.span.start as usize), + column: node.span.start as usize, + scope, + }); + + // push scope before walking children + self.scope_stack.push(name); + + // collect parameters + for param in &node.params.items { + if let BindingPattern::BindingIdentifier(ident) = ¶m.pattern { + self.symbols.push(Symbol { + name: ident.name.to_string(), + kind: SymbolKind::Parameter, + file: self.file_path.to_string(), + line: self.span_to_line(param.span.start as usize), + column: param.span.start as usize, + scope: self.current_scope(), + }); + } + } + + oxc::ast_visit::walk::walk_function(self, node, flags); + self.scope_stack.pop(); // restore after + } else { + // anonymous function, still walk + oxc::ast_visit::walk::walk_function(self, node, flags); + } } - pub fn list_possible_threats(self) -> Vec { - self.findings + // visit classes + fn visit_class(&mut self, node: &oxc_ast::ast::Class<'a>) { + if let Some(id) = &node.id { + let name = id.name.to_string(); + + self.symbols.push(Symbol { + name: name.clone(), + kind: SymbolKind::Class, + file: self.file_path.to_string(), + line: self.span_to_line(node.span.start as usize), + column: node.span.start as usize, + scope: self.current_scope(), + }); + + self.scope_stack.push(name); + oxc::ast_visit::walk::walk_class(self, node); + self.scope_stack.pop(); + } else { + oxc::ast_visit::walk::walk_class(self, node); + } + } + + // methods are class functions + // e.g. + /* class App{ + method(ab){} + } */ + fn visit_method_definition(&mut self, node: &oxc_ast::ast::MethodDefinition<'a>) { + if let oxc_ast::ast::PropertyKey::StaticIdentifier(ident) = &node.key { + let name = ident.name.to_string(); + + self.symbols.push(Symbol { + name: name.clone(), + kind: SymbolKind::Method, + file: self.file_path.to_string(), + line: self.span_to_line(node.span.start as usize), + column: node.span.start as usize, + scope: self.current_scope(), + }); + + // push method name onto scope + self.scope_stack.push(name); + + // collect parameters + for param in &node.value.params.items { + if let BindingPattern::BindingIdentifier(ident) = ¶m.pattern { + self.symbols.push(Symbol { + name: ident.name.to_string(), + kind: SymbolKind::Parameter, + file: self.file_path.to_string(), + line: self.span_to_line(param.span.start as usize), + column: param.span.start as usize, + scope: self.current_scope(), // now inside method scope + }); + } + } + + oxc::ast_visit::walk::walk_method_definition(self, node); + self.scope_stack.pop(); + } else { + oxc::ast_visit::walk::walk_method_definition(self, node); + } + } + + // e.g. import 'express' from express + fn visit_import_declaration(&mut self, node: &oxc_ast::ast::ImportDeclaration<'a>) { + if let Some(specifiers) = &node.specifiers { + for specifier in specifiers { + let name = match specifier { + oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(s) => { + s.local.name.to_string() + } + oxc_ast::ast::ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => { + s.local.name.to_string() + } + oxc_ast::ast::ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => { + s.local.name.to_string() + } + }; + + self.symbols.push(Symbol { + name, + kind: SymbolKind::Import, + file: self.file_path.to_string(), + line: self.span_to_line(node.span.start as usize), + column: node.span.start as usize, + scope: "global".to_string(), // imports are always global + }); + } + } + oxc::ast_visit::walk::walk_import_declaration(self, node); } } diff --git a/src/server/detectors/shared/sarif.rs b/src/server/detectors/shared/sarif.rs index f83af1f..f764198 100644 --- a/src/server/detectors/shared/sarif.rs +++ b/src/server/detectors/shared/sarif.rs @@ -24,6 +24,7 @@ pub fn to_sarif(all_findings: &[FinalFindings]) -> Sarif { let region = Region::builder() .start_line(finding.line_no.parse::().unwrap_or(1)) + .start_column(finding.col_no.parse().unwrap_or(1)) .build(); let location = Location::builder() diff --git a/src/server/detectors/typescript/tests.rs b/src/server/detectors/typescript/tests.rs index 7b7996f..42496fa 100644 --- a/src/server/detectors/typescript/tests.rs +++ b/src/server/detectors/typescript/tests.rs @@ -9,8 +9,9 @@ mod test { #[test] fn detects_eval_wrapped_in_as_any() { let code = r#"(eval as any)("alert(1)");"#; - let findings = SCANNER.scan(code, "test.ts"); - assert!(findings + let scan_result = SCANNER.scan(code, "test.ts"); + assert!(scan_result + .findings .iter() .any(|f| f.vuln_type == VulnerabilityType::Eval)); } @@ -22,8 +23,9 @@ mod test { password: "secret123" } satisfies Config; "#; - let findings = SCANNER.scan(code, "test.ts"); - assert!(findings + let scan_result = SCANNER.scan(code, "test.ts"); + assert!(scan_result + .findings .iter() .any(|f| f.vuln_type == VulnerabilityType::HardcodedSecret)); } @@ -31,8 +33,9 @@ mod test { #[test] fn detects_as_any_usage() { let code = r#"const x = userInput as any;"#; - let findings = SCANNER.scan(code, "test.ts"); - assert!(findings + let scan_result = SCANNER.scan(code, "test.ts"); + assert!(scan_result + .findings .iter() .any(|f| f.vuln_type == VulnerabilityType::UnsafeTypeAssertion)); } @@ -40,8 +43,9 @@ mod test { #[test] fn detects_eval_nested_in_call_arguments() { let code = r#"db.query(eval("SELECT * FROM " + table));"#; - let findings = SCANNER.scan(code, "test.ts"); - assert!(findings + let scan_result = SCANNER.scan(code, "test.ts"); + assert!(scan_result + .findings .iter() .any(|f| f.vuln_type == VulnerabilityType::Eval)); } diff --git a/src/server/detectors/typescript/ts_scanner.rs b/src/server/detectors/typescript/ts_scanner.rs index c8d553b..568e2f1 100644 --- a/src/server/detectors/typescript/ts_scanner.rs +++ b/src/server/detectors/typescript/ts_scanner.rs @@ -1,5 +1,6 @@ use crate::server::detectors::shared::common_parser::{parse_to_ast, CodeVisitor}; use crate::server::detectors::Scanner; +use crate::server::models::results::ScanResult; use crate::Findings; use oxc::allocator::Allocator; use oxc::ast_visit::Visit; @@ -10,24 +11,23 @@ pub struct TypeScriptScanner; // hardcoded secrets // SQL Injection vulnerabilities impl Scanner for TypeScriptScanner { - fn scan(&self, code: &str, file_path: &str) -> Vec { + fn scan(&self, code: &str, file_path: &str) -> ScanResult { let allocator = Allocator::default(); let ast = match parse_to_ast(code, &allocator, file_path) { Ok(program) => program, Err(e) => { eprintln!("Parse error: {}", e); - return vec![]; + return ScanResult { + findings: vec![], + symbols: vec![], + calls: vec![], + }; } }; - let mut visitor = CodeVisitor { - findings: Vec::new(), - file_path, - source_text: code, - }; - + let mut visitor = CodeVisitor::new(file_path, code); visitor.visit_program(&ast); - visitor.list_possible_threats() + visitor.into_scan_result() } } diff --git a/src/server/handlers/handler.rs b/src/server/handlers/handler.rs index 18304b9..6261905 100644 --- a/src/server/handlers/handler.rs +++ b/src/server/handlers/handler.rs @@ -16,8 +16,8 @@ pub async fn get_results( let state_read = state.read().await; - if let Some(findings) = state_read.results.get(&scan_id) { - Json(findings).into_response() + if let Some(scan_result) = state_read.results.get(&scan_id) { + Json(&scan_result.findings).into_response() } else { StatusCode::NOT_FOUND.into_response() } diff --git a/src/server/models/calls.rs b/src/server/models/calls.rs new file mode 100644 index 0000000..3694da3 --- /dev/null +++ b/src/server/models/calls.rs @@ -0,0 +1,31 @@ +use std::collections::HashMap; + +pub struct CallSite { + pub callee: String, // function name + pub object: Option, // e.g. db.query, db is object + pub arguments: Vec, + pub caller: String, // not to be confused with calle + pub file: String, + pub line: usize, + pub column: usize, +} + +pub struct CallTable { + pub calls: HashMap>, +} + +impl CallTable { + pub fn new() -> Self { + Self { + calls: HashMap::new(), + } + } + + pub fn insert(&mut self, file: String, call: CallSite) { + self.calls.entry(file).or_insert_with(Vec::new).push(call); + } + + pub fn total(&self) -> usize { + self.calls.values().map(|v| v.len()).sum() + } +} diff --git a/src/server/models/findings.rs b/src/server/models/findings.rs index bf811eb..e3d58ea 100644 --- a/src/server/models/findings.rs +++ b/src/server/models/findings.rs @@ -116,6 +116,7 @@ impl Debug for Severity { pub struct Findings { pub vuln_type: VulnerabilityType, pub line_no: String, + pub col_no: String, pub file_path: String, pub snippet: String, pub severity: Severity, diff --git a/src/server/models/mod.rs b/src/server/models/mod.rs index 02995fe..122c4a1 100644 --- a/src/server/models/mod.rs +++ b/src/server/models/mod.rs @@ -1 +1,4 @@ +pub mod calls; pub mod findings; +pub mod results; +pub mod symbols; diff --git a/src/server/models/results.rs b/src/server/models/results.rs new file mode 100644 index 0000000..a10ffe1 --- /dev/null +++ b/src/server/models/results.rs @@ -0,0 +1,9 @@ +use super::calls::CallSite; +use super::findings::Findings; +use super::symbols::Symbol; + +pub struct ScanResult { + pub findings: Vec, + pub symbols: Vec, + pub calls: Vec, +} diff --git a/src/server/models/symbols.rs b/src/server/models/symbols.rs new file mode 100644 index 0000000..1bdf3c4 --- /dev/null +++ b/src/server/models/symbols.rs @@ -0,0 +1,24 @@ +use std::collections::HashMap; +#[derive(Debug)] +pub enum SymbolKind { + Function, + Method, + Variable, + Parameter, + Import, + Class, + Struct, +} + +pub struct Symbol { + pub name: String, + pub kind: SymbolKind, + pub file: String, + pub line: usize, + pub column: usize, + pub scope: String, +} + +pub struct SymbolTable { + pub symbols: HashMap>, +} diff --git a/src/server/service.rs b/src/server/service.rs index 63369ff..7e5c90e 100644 --- a/src/server/service.rs +++ b/src/server/service.rs @@ -1,5 +1,6 @@ use crate::server::detectors::{GolangScanner, JavaScriptScanner, Scanner, TypeScriptScanner}; -use crate::server::models::findings::Findings; +use crate::server::models::results::ScanResult; + use std::collections::HashMap; #[derive(Eq, Hash, PartialEq)] @@ -26,21 +27,24 @@ impl OWASPScanner { OWASPScanner { scanners } } - pub fn scan(&self, codebase: &str, file_path: &str) -> Vec { - let mut all_findings: Vec = Vec::new(); + pub fn scan(&self, codebase: &str, file_path: &str) -> ScanResult { + let dummy = ScanResult { + findings: Vec::new(), + symbols: Vec::new(), + calls: Vec::new(), + }; let language = match Self::determine_language(file_path) { Some(lang) => lang, - None => return all_findings, + None => return dummy, }; let scanner = match self.scanners.get(&language) { Some(d) => d, - None => return all_findings, + None => return dummy, }; - let findings = scanner.scan(codebase, file_path); - all_findings.extend(findings); - all_findings + let scan_result = scanner.scan(codebase, file_path); + scan_result } // this can gow to support more languages diff --git a/src/state.rs b/src/state.rs index 6b7f853..f642f59 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,9 +1,14 @@ -use crate::server::models::findings::FinalFindings; +use crate::server::models::{calls::CallTable, findings::FinalFindings, symbols::SymbolTable}; use std::collections::HashMap; +pub struct ScanData { + pub findings: Vec, + pub symbol_table: SymbolTable, + pub call_table: CallTable, +} pub struct AppState { - // HashMap>> - pub results: HashMap>, + // HashMap,SymbolTable]>> + pub results: HashMap, } impl AppState {