Feat/full cfg (#30)

* feat: Enhance control flow analysis with function summaries and taint analysis

* feat: Update taint analysis to utilize function summaries for enhanced tracking

* Refactor `walk.rs` batch processing and override handling:

- Renamed `Batcher` to `BatchSender` for clarity.
- Added `BatchSender::new` constructor for cleaner initialization.
- Simplified batch size management in `BatchSender`.
- Extracted `build_overrides` function for reusable override construction.
- Improved error handling and validation in override building.
- Enhanced performance with directory and file type filtering in `walk`.

* Improve logging and streamline directory walk process:

- Added detailed `tracing` logs for debugging batch flushes, override construction, and walk initialization/completion.
- Optimized and simplified `filter_entry` logic for directory and file type filters.
- Improved metadata checks and max file size enforcement during the scan.

* Refactor and optimize taint tracking, label rules, and directory walk process:

- Replaced `DefaultHasher` with `blake3::Hasher` for improved taint hashing.
- Enhanced sorting and hashing logic in `taint.rs` for consistency and efficiency.
- Removed unused `set_hash` function and redundant imports across files.
- Improved batch sender logic in `walk.rs`, renaming key components for clarity.
- Unified `spawn_senders` and `spawn_file_walker` with thread handling and channel tuple return.
- Expanded label rules with additional matchers for sources, sanitizers, and sinks.
- Deprecated `dump_cfg` and specific logging utilities in `cfg.rs` for code cleanup.

* fix: fixed let chains error in walk.rs

* fix: updated dependencies

* fix: updated dependencies

* chore: Remove standard error in scan.rs

* feat: Introduce function summaries for enhanced taint and control flow analysis

* feat: Enhance taint analysis with interop support and function summaries

* feat: Add configuration analysis module and enhance matcher rules

* feat: Add arity column to function_summaries and handle schema migration

* fix: fixed clippy &PathBuf warnings

* chore: Update dependencies and versioning in Cargo files

* docs: Update README to enhance clarity and detail on features and analysis modes

* chore: Update CHANGELOG for version 0.2.0 with new features, changes, and fixes

* docs: Update SECURITY.md to clarify version support status

---------

Co-authored-by: elipeter <eli.peter@es.fcm.travel>
This commit is contained in:
Eli Peter 2026-02-24 23:44:07 -05:00 committed by GitHub
parent 8cbbec7d90
commit f96a89e7c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 11505 additions and 1099 deletions

252
src/summary/mod.rs Normal file
View file

@ -0,0 +1,252 @@
use crate::labels::{Cap, DataLabel};
use crate::symbol::{FuncKey, Lang, normalize_namespace};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Serialisable summary of a single function's taint behaviour.
///
/// One of these is produced per function during **pass 1** of a scan and
/// persisted to the `function_summaries` SQLite table. During **pass 2** the
/// full set of summaries across every file is loaded into memory so the taint
/// engine can resolve crossfile calls.
///
/// Design notes
/// ────────────
/// * **All three cap fields are independent.** A function can simultaneously
/// act as a source (introduces fresh taint), a sanitizer (cleans certain
/// bits), and a sink (passes tainted data to a dangerous operation).
/// The old code picked a single `DataLabel` which lost information.
///
/// * **`propagates_taint`** captures passthrough behaviour: if an input
/// parameter is tainted, does the return value carry that taint? This is
/// essential for chains like `let y = transform(tainted_x); sink(y);`.
///
/// * **`callees`** are recorded for future callgraph construction
/// (topological analysis, approach 2) but are not used in pass1/pass2
/// taint resolution yet.
///
/// * **`tainted_sink_params`** marks which parameter *positions* flow to
/// internal sinks. Today the taint engine treats the whole call as a
/// single "tainted or not" question; this field futureproofs the summary
/// for perargument precision.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FuncSummary {
/// Function name as it appears in the source (`my_func`, not the full path).
pub name: String,
/// Absolute path of the file that defines this function.
pub file_path: String,
/// Language slug (`"rust"`, `"javascript"`, …).
pub lang: String,
// ── Signature information ────────────────────────────────────────────
/// Total number of parameters (including `self`/`&self` for methods).
pub param_count: usize,
/// Parameter names in declaration order.
pub param_names: Vec<String>,
// ── Taint behaviour ──────────────────────────────────────────────────
// Stored as raw `u8` so serde doesn't need to know about `bitflags`.
/// Caps this function **introduces** — i.e. the return value carries
/// freshlytainted data even if no argument was tainted.
pub source_caps: u8,
/// Caps this function **cleans** — passing tainted data through this
/// function strips the corresponding bits.
pub sanitizer_caps: u8,
/// Caps this function **consumes unsafely** — calling it with tainted
/// arguments that still carry these bits is a finding.
pub sink_caps: u8,
/// `true` when taint on *any* input parameter can flow through to the
/// return value. Conservative: set to `true` if *any* code path
/// propagates an argument to the return expression.
pub propagates_taint: bool,
/// Indices of parameters that flow to internal sinks (0based).
pub tainted_sink_params: Vec<usize>,
/// Names of functions/methods/macros called inside this function body.
/// Stored for future callgraph / topologicalsort analysis.
pub callees: Vec<String>,
}
// ── Cap conversion helpers ──────────────────────────────────────────────
impl FuncSummary {
#[inline]
pub fn source_caps(&self) -> Cap {
Cap::from_bits_truncate(self.source_caps)
}
#[inline]
pub fn sanitizer_caps(&self) -> Cap {
Cap::from_bits_truncate(self.sanitizer_caps)
}
#[inline]
pub fn sink_caps(&self) -> Cap {
Cap::from_bits_truncate(self.sink_caps)
}
/// Collapse the three independent cap fields back into the single
/// `DataLabel` that the current taint engine expects.
///
/// Priority: **Sink > Source > Sanitizer**. Sinks first because
/// missing a dangerous callsite is worse than a falsepositive on a
/// source. Sources beat sanitizers because an untracked source is
/// a missed vulnerability, while an untracked sanitizer only causes
/// false positives.
#[allow(dead_code)]
pub fn primary_label(&self) -> Option<DataLabel> {
let sink = self.sink_caps();
let src = self.source_caps();
let san = self.sanitizer_caps();
if !sink.is_empty() {
Some(DataLabel::Sink(sink))
} else if !src.is_empty() {
Some(DataLabel::Source(src))
} else if !san.is_empty() {
Some(DataLabel::Sanitizer(san))
} else {
None
}
}
/// Returns `true` when this function has **any** observable taint
/// effect — it is a source, sanitizer, sink, or propagates taint.
#[allow(dead_code)]
pub fn is_interesting(&self) -> bool {
self.source_caps != 0
|| self.sanitizer_caps != 0
|| self.sink_caps != 0
|| self.propagates_taint
}
/// Build a [`FuncKey`] from this summary, normalizing the namespace
/// relative to `scan_root`.
pub fn func_key(&self, scan_root: Option<&str>) -> FuncKey {
FuncKey {
lang: Lang::from_slug(&self.lang).unwrap_or(Lang::Rust),
namespace: normalize_namespace(&self.file_path, scan_root),
name: self.name.clone(),
arity: Some(self.param_count),
}
}
}
// ── Lookup map used by the taint engine ─────────────────────────────────
/// A merged view of all function summaries keyed by qualified [`FuncKey`].
///
/// Functions are partitioned by language + namespace + name + arity. Two
/// functions with the same bare name but different languages or namespaces
/// are stored separately — no implicit cross-language merging occurs.
///
/// A secondary index `(Lang, name)` supports fast lookup by language + name
/// for same-language resolution in the taint engine.
#[derive(Default)]
pub struct GlobalSummaries {
by_key: HashMap<FuncKey, FuncSummary>,
by_lang_name: HashMap<(Lang, String), Vec<FuncKey>>,
}
impl GlobalSummaries {
pub fn new() -> Self {
Self::default()
}
/// Insert or merge a summary. If an exact `FuncKey` match exists,
/// merge conservatively (OR caps/booleans, union params/callees).
pub fn insert(&mut self, key: FuncKey, summary: FuncSummary) {
let lang = key.lang;
let name = key.name.clone();
self.by_key
.entry(key.clone())
.and_modify(|existing| {
existing.source_caps |= summary.source_caps;
existing.sanitizer_caps |= summary.sanitizer_caps;
existing.sink_caps |= summary.sink_caps;
existing.propagates_taint |= summary.propagates_taint;
for &idx in &summary.tainted_sink_params {
if !existing.tainted_sink_params.contains(&idx) {
existing.tainted_sink_params.push(idx);
}
}
for c in &summary.callees {
if !existing.callees.contains(c) {
existing.callees.push(c.clone());
}
}
})
.or_insert(summary);
let keys = self.by_lang_name.entry((lang, name)).or_default();
if !keys.contains(&key) {
keys.push(key);
}
}
/// Exact lookup by fully-qualified key.
pub fn get(&self, key: &FuncKey) -> Option<&FuncSummary> {
self.by_key.get(key)
}
/// All same-language matches for a bare function name.
pub fn lookup_same_lang(&self, lang: Lang, name: &str) -> Vec<(&FuncKey, &FuncSummary)> {
self.by_lang_name
.get(&(lang, name.to_string()))
.map(|keys| {
keys.iter()
.filter_map(|k| self.by_key.get(k).map(|v| (k, v)))
.collect()
})
.unwrap_or_default()
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.by_key.is_empty()
}
/// Iterate over all (key, summary) pairs.
#[allow(dead_code)]
pub fn iter(&self) -> impl Iterator<Item = (&FuncKey, &FuncSummary)> {
self.by_key.iter()
}
}
impl std::fmt::Debug for GlobalSummaries {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GlobalSummaries")
.field("len", &self.by_key.len())
.finish()
}
}
/// Merge a set of perfile summaries into a single `GlobalSummaries` map.
///
/// Merging only happens for exact `FuncKey` matches (same lang + namespace +
/// name + arity). Functions with the same bare name but different languages
/// or namespaces are stored separately.
pub fn merge_summaries(
per_file: impl IntoIterator<Item = FuncSummary>,
scan_root: Option<&str>,
) -> GlobalSummaries {
let mut map = GlobalSummaries::new();
for fs in per_file {
let key = fs.func_key(scan_root);
map.insert(key, fs);
}
map
}
#[cfg(test)]
mod tests;

258
src/summary/tests.rs Normal file
View file

@ -0,0 +1,258 @@
use super::*;
fn make(name: &str, src: u8, san: u8, sink: u8) -> FuncSummary {
FuncSummary {
name: name.into(),
file_path: "test.rs".into(),
lang: "rust".into(),
param_count: 0,
param_names: vec![],
source_caps: src,
sanitizer_caps: san,
sink_caps: sink,
propagates_taint: false,
tainted_sink_params: vec![],
callees: vec![],
}
}
#[test]
fn primary_label_priority() {
// sink beats everything
let s = make("f", 0xFF, 0xFF, 0x01);
assert!(matches!(s.primary_label(), Some(DataLabel::Sink(_))));
// source beats sanitizer
let s = make("f", 0x01, 0x02, 0x00);
assert!(matches!(s.primary_label(), Some(DataLabel::Source(_))));
// sanitizer alone
let s = make("f", 0x00, 0x04, 0x00);
assert!(matches!(s.primary_label(), Some(DataLabel::Sanitizer(_))));
// nothing
let s = make("f", 0, 0, 0);
assert!(s.primary_label().is_none());
}
#[test]
fn merge_unions_conservatively() {
let a = make("foo", 0x01, 0x00, 0x00);
let b = FuncSummary {
sink_caps: 0x04,
propagates_taint: true,
tainted_sink_params: vec![0],
callees: vec!["bar".into()],
..make("foo", 0x00, 0x02, 0x00)
};
let merged = merge_summaries(vec![a, b], None);
let key = FuncKey {
lang: Lang::Rust,
namespace: "test.rs".into(),
name: "foo".into(),
arity: Some(0),
};
let foo = merged.get(&key).unwrap();
assert_eq!(foo.source_caps, 0x01);
assert_eq!(foo.sanitizer_caps, 0x02);
assert_eq!(foo.sink_caps, 0x04);
assert!(foo.propagates_taint);
assert_eq!(foo.tainted_sink_params, vec![0]);
assert_eq!(foo.callees, vec!["bar".to_string()]);
}
#[test]
fn is_interesting_detects_all_cases() {
assert!(!make("f", 0, 0, 0).is_interesting());
assert!(make("f", 1, 0, 0).is_interesting());
assert!(make("f", 0, 1, 0).is_interesting());
assert!(make("f", 0, 0, 1).is_interesting());
let mut p = make("f", 0, 0, 0);
p.propagates_taint = true;
assert!(p.is_interesting());
}
#[test]
fn same_lang_different_namespace_no_merge() {
let a = FuncSummary {
name: "helper".into(),
file_path: "file_a.rs".into(),
lang: "rust".into(),
param_count: 0,
param_names: vec![],
source_caps: Cap::all().bits(),
sanitizer_caps: 0,
sink_caps: 0,
propagates_taint: false,
tainted_sink_params: vec![],
callees: vec![],
};
let b = FuncSummary {
name: "helper".into(),
file_path: "file_b.rs".into(),
lang: "rust".into(),
param_count: 0,
param_names: vec![],
source_caps: 0,
sanitizer_caps: 0,
sink_caps: Cap::SHELL_ESCAPE.bits(),
propagates_taint: false,
tainted_sink_params: vec![],
callees: vec![],
};
let global = merge_summaries(vec![a, b], None);
// They should be stored under different FuncKeys
let key_a = FuncKey {
lang: Lang::Rust,
namespace: "file_a.rs".into(),
name: "helper".into(),
arity: Some(0),
};
let key_b = FuncKey {
lang: Lang::Rust,
namespace: "file_b.rs".into(),
name: "helper".into(),
arity: Some(0),
};
assert!(global.get(&key_a).is_some());
assert!(global.get(&key_b).is_some());
// source_caps NOT merged
assert_eq!(global.get(&key_a).unwrap().source_caps, Cap::all().bits());
assert_eq!(global.get(&key_b).unwrap().source_caps, 0);
}
#[test]
fn same_lang_same_namespace_merges() {
let a = FuncSummary {
name: "helper".into(),
file_path: "lib.rs".into(),
lang: "rust".into(),
param_count: 0,
param_names: vec![],
source_caps: 0x01,
sanitizer_caps: 0,
sink_caps: 0,
propagates_taint: false,
tainted_sink_params: vec![],
callees: vec![],
};
let b = FuncSummary {
name: "helper".into(),
file_path: "lib.rs".into(),
lang: "rust".into(),
param_count: 0,
param_names: vec![],
source_caps: 0,
sanitizer_caps: 0x02,
sink_caps: 0,
propagates_taint: true,
tainted_sink_params: vec![],
callees: vec![],
};
let global = merge_summaries(vec![a, b], None);
let key = FuncKey {
lang: Lang::Rust,
namespace: "lib.rs".into(),
name: "helper".into(),
arity: Some(0),
};
let merged = global.get(&key).unwrap();
assert_eq!(merged.source_caps, 0x01);
assert_eq!(merged.sanitizer_caps, 0x02);
assert!(merged.propagates_taint);
}
#[test]
fn cross_lang_name_collision_stays_separate() {
let py = FuncSummary {
name: "process_data".into(),
file_path: "handler.py".into(),
lang: "python".into(),
param_count: 0,
param_names: vec![],
source_caps: Cap::all().bits(),
sanitizer_caps: 0,
sink_caps: 0,
propagates_taint: false,
tainted_sink_params: vec![],
callees: vec![],
};
let c = FuncSummary {
name: "process_data".into(),
file_path: "handler.c".into(),
lang: "c".into(),
param_count: 1,
param_names: vec!["s".into()],
source_caps: 0,
sanitizer_caps: 0,
sink_caps: 0,
propagates_taint: true,
tainted_sink_params: vec![],
callees: vec![],
};
let global = merge_summaries(vec![py, c], None);
let py_key = FuncKey {
lang: Lang::Python,
namespace: "handler.py".into(),
name: "process_data".into(),
arity: Some(0),
};
let c_key = FuncKey {
lang: Lang::C,
namespace: "handler.c".into(),
name: "process_data".into(),
arity: Some(1),
};
assert!(global.get(&py_key).is_some());
assert!(global.get(&c_key).is_some());
// Python's source_caps NOT merged into C
assert_eq!(global.get(&c_key).unwrap().source_caps, 0);
assert_eq!(global.get(&py_key).unwrap().source_caps, Cap::all().bits());
}
#[test]
fn lookup_same_lang_returns_all_matches() {
let a = FuncSummary {
name: "helper".into(),
file_path: "a.rs".into(),
lang: "rust".into(),
param_count: 0,
param_names: vec![],
source_caps: 1,
sanitizer_caps: 0,
sink_caps: 0,
propagates_taint: false,
tainted_sink_params: vec![],
callees: vec![],
};
let b = FuncSummary {
name: "helper".into(),
file_path: "b.rs".into(),
lang: "rust".into(),
param_count: 0,
param_names: vec![],
source_caps: 2,
sanitizer_caps: 0,
sink_caps: 0,
propagates_taint: false,
tainted_sink_params: vec![],
callees: vec![],
};
let global = merge_summaries(vec![a, b], None);
let matches = global.lookup_same_lang(Lang::Rust, "helper");
assert_eq!(matches.len(), 2);
// No cross-language matches
let py_matches = global.lookup_same_lang(Lang::Python, "helper");
assert!(py_matches.is_empty());
}