Skip to content
Merged
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
40 changes: 40 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
120 changes: 83 additions & 37 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -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<RwLock<AppState>>) -> 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<FinalFindings> = entries
// collect both findings AND symbols AND callsites in parallel
let all_results: Vec<(FinalFindings, Vec<Symbol>, Vec<CallSite>)> = 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);
Expand All @@ -65,8 +61,58 @@ pub async fn scan(path: String, state: Arc<RwLock<AppState>>) -> 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<String, Vec<Symbol>> = 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<String, Vec<CallSite>> = 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
}
47 changes: 45 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +23,9 @@ enum Command {
output: Option<String>,
},
Serve,
Analyze {
path: String,
},
}

#[tokio::main]
Expand Down Expand Up @@ -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();
}
Expand All @@ -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);
}
}
}
}
16 changes: 8 additions & 8 deletions src/server/detectors/golang/exec_injection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,39 +46,39 @@ 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,
);
}
}
}

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,
);
Expand Down
Loading
Loading