mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-12 21:02:11 +02:00
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:
parent
8cbbec7d90
commit
f96a89e7c1
87 changed files with 11505 additions and 1099 deletions
252
src/summary/mod.rs
Normal file
252
src/summary/mod.rs
Normal 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 cross‑file 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 pass‑through 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 call‑graph construction
|
||||
/// (topological analysis, approach 2) but are not used in pass‑1/pass‑2
|
||||
/// 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 future‑proofs the summary
|
||||
/// for per‑argument 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
|
||||
/// freshly‑tainted 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 (0‑based).
|
||||
pub tainted_sink_params: Vec<usize>,
|
||||
|
||||
/// Names of functions/methods/macros called inside this function body.
|
||||
/// Stored for future call‑graph / topological‑sort 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 call‑site is worse than a false‑positive on a
|
||||
/// source. Sources beat sanitizers because an un‑tracked source is
|
||||
/// a missed vulnerability, while an un‑tracked 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 per‑file 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
258
src/summary/tests.rs
Normal 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());
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue