* chore: Exclude CLAUDE.md from Cargo.toml

* feat: add callgraph module and integrate into main analysis flow

* feat: enhance CLI with new severity filtering and analysis modes

* feat: update CHANGELOG with recent enhancements and fixes to severity filtering and output handling

* feat: implement state-model dataflow analysis for resource lifecycle and auth state

* feat: enhance diagnostic output formatting and add evidence structure

* feat: implement attack surface ranking for diagnostics with scoring and sorting

* feat: add comprehensive documentation for installation, usage, and rules reference

* feat: add multiple language support for command execution and evaluation endpoints

* feat: implement inline suppression for findings using `nyx:ignore` comments

* feat: add confidence levels to AST patterns and update output structure

* feat: implement low-noise prioritization system with category filtering, rollup grouping, and configurable budgets

* feat: bump version to 0.4.0 and update changelog with new features and improvements

* feat: add dead code allowances to various functions in mod.rs and real_world_tests.rs
This commit is contained in:
Eli Peter 2026-02-25 21:16:36 -05:00 committed by GitHub
parent 19b578c5c4
commit 1bbe4b1cfb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
456 changed files with 25628 additions and 1228 deletions

View file

@ -31,6 +31,10 @@ pub static RULES: &[LabelRule] = &[
matchers: &["printf", "fprintf"],
label: DataLabel::Sink(Cap::FMT_STRING),
},
LabelRule {
matchers: &["fopen", "open"],
label: DataLabel::Sink(Cap::FILE_IO),
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {
@ -39,6 +43,9 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
"while_statement" => Kind::While,
"for_statement" => Kind::For,
"do_statement" => Kind::While,
"switch_statement" => Kind::Block,
"case_statement" => Kind::Block,
"labeled_statement" => Kind::Block,
"return_statement" => Kind::Return,
"break_statement" => Kind::Break,
@ -47,6 +54,7 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
// structure
"translation_unit" => Kind::SourceFile,
"compound_statement" => Kind::Block,
"else_clause" => Kind::Block,
"function_definition" => Kind::Function,
// data-flow

View file

@ -29,6 +29,10 @@ pub static RULES: &[LabelRule] = &[
matchers: &["printf", "fprintf"],
label: DataLabel::Sink(Cap::FMT_STRING),
},
LabelRule {
matchers: &["fopen", "open"],
label: DataLabel::Sink(Cap::FILE_IO),
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {
@ -38,15 +42,23 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
"for_statement" => Kind::For,
"for_range_loop" => Kind::For,
"do_statement" => Kind::While,
"switch_statement" => Kind::Block,
"case_statement" => Kind::Block,
"labeled_statement" => Kind::Block,
"return_statement" => Kind::Return,
"throw_statement" => Kind::Return,
"break_statement" => Kind::Break,
"continue_statement" => Kind::Continue,
// structure
"translation_unit" => Kind::SourceFile,
"compound_statement" => Kind::Block,
"else_clause" => Kind::Block,
"function_definition" => Kind::Function,
"try_statement" => Kind::Block,
"catch_clause" => Kind::Block,
"lambda_expression" => Kind::Block,
// data-flow
"call_expression" => Kind::CallFn,
@ -63,7 +75,7 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
"preproc_include" => Kind::Trivia,
"preproc_def" => Kind::Trivia,
"using_declaration" => Kind::Trivia,
"namespace_definition" => Kind::Trivia,
"namespace_definition" => Kind::Block,
};
pub static PARAM_CONFIG: ParamConfig = ParamConfig {

View file

@ -8,7 +8,17 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Source(Cap::all()),
},
LabelRule {
matchers: &["http.Request", "r.FormValue", "r.URL"],
matchers: &[
"http.Request",
"r.FormValue",
"r.URL",
"r.Body",
"r.Header",
"r.URL.Query",
"r.URL.Query.Get",
"Request.FormValue",
"Request.URL",
],
label: DataLabel::Source(Cap::all()),
},
// ───────── Sanitizers ──────────
@ -17,18 +27,40 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Sanitizer(Cap::HTML_ESCAPE),
},
LabelRule {
matchers: &["url.QueryEscape"],
matchers: &["url.QueryEscape", "url.PathEscape"],
label: DataLabel::Sanitizer(Cap::URL_ENCODE),
},
LabelRule {
matchers: &["filepath.Clean", "filepath.Base"],
label: DataLabel::Sanitizer(Cap::FILE_IO),
},
// ─────────── Sinks ─────────────
LabelRule {
matchers: &["exec.Command"],
label: DataLabel::Sink(Cap::SHELL_ESCAPE),
},
LabelRule {
matchers: &["db.Query", "db.Exec"],
matchers: &["db.Query", "db.Exec", "db.QueryRow", "db.Prepare"],
label: DataLabel::Sink(Cap::SHELL_ESCAPE),
},
LabelRule {
matchers: &["fmt.Fprintf", "fmt.Sprintf", "fmt.Printf"],
label: DataLabel::Sink(Cap::FMT_STRING),
},
LabelRule {
matchers: &[
"os.Open",
"os.OpenFile",
"os.Create",
"ioutil.ReadFile",
"os.ReadFile",
],
label: DataLabel::Sink(Cap::FILE_IO),
},
LabelRule {
matchers: &["template.HTML"],
label: DataLabel::Sink(Cap::HTML_ESCAPE),
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {
@ -46,6 +78,16 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
"statement_list" => Kind::Block,
"function_declaration" => Kind::Function,
"method_declaration" => Kind::Function,
"func_literal" => Kind::Function,
"expression_switch_statement" => Kind::Block,
"type_switch_statement" => Kind::Block,
"expression_case" => Kind::Block,
"type_case" => Kind::Block,
"default_case" => Kind::Block,
"select_statement" => Kind::Block,
"communication_case" => Kind::Block,
"go_statement" => Kind::Block,
"defer_statement" => Kind::Block,
// data-flow
"call_expression" => Kind::CallFn,

View file

@ -8,7 +8,19 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Source(Cap::all()),
},
LabelRule {
matchers: &["getParameter", "getInputStream", "getHeader", "getCookies"],
matchers: &[
"getParameter",
"getInputStream",
"getHeader",
"getCookies",
"getReader",
"getQueryString",
"getPathInfo",
],
label: DataLabel::Source(Cap::all()),
},
LabelRule {
matchers: &["readObject", "readLine"],
label: DataLabel::Source(Cap::all()),
},
// ───────── Sanitizers ──────────
@ -18,13 +30,21 @@ pub static RULES: &[LabelRule] = &[
},
// ─────────── Sinks ─────────────
LabelRule {
matchers: &["Runtime.exec"],
matchers: &["Runtime.exec", "ProcessBuilder"],
label: DataLabel::Sink(Cap::SHELL_ESCAPE),
},
LabelRule {
matchers: &["executeQuery", "executeUpdate", "prepareStatement"],
label: DataLabel::Sink(Cap::SHELL_ESCAPE),
},
LabelRule {
matchers: &["Class.forName"],
label: DataLabel::Sink(Cap::SHELL_ESCAPE),
},
LabelRule {
matchers: &["println", "print", "write"],
label: DataLabel::Sink(Cap::HTML_ESCAPE),
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {
@ -33,8 +53,10 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
"while_statement" => Kind::While,
"for_statement" => Kind::For,
"enhanced_for_statement" => Kind::For,
"do_statement" => Kind::While,
"return_statement" => Kind::Return,
"throw_statement" => Kind::Return,
"break_statement" => Kind::Break,
"continue_statement" => Kind::Continue,
@ -46,6 +68,15 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
"interface_body" => Kind::Block,
"method_declaration" => Kind::Function,
"constructor_declaration" => Kind::Function,
"switch_expression" => Kind::Block,
"switch_block" => Kind::Block,
"switch_block_statement_group" => Kind::Block,
"try_statement" => Kind::Block,
"catch_clause" => Kind::Block,
"finally_clause" => Kind::Block,
"lambda_expression" => Kind::Block,
"constructor_body" => Kind::Block,
"static_initializer" => Kind::Block,
// data-flow
"method_invocation" => Kind::CallMethod,

View file

@ -62,6 +62,7 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
"while_statement" => Kind::While,
"for_statement" => Kind::For,
"for_in_statement" => Kind::For,
"do_statement" => Kind::While,
"return_statement" => Kind::Return,
"throw_statement" => Kind::Return,
@ -71,9 +72,24 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
// structure
"program" => Kind::SourceFile,
"statement_block" => Kind::Block,
"else_clause" => Kind::Block,
"function_declaration" => Kind::Function,
"function_expression" => Kind::Function,
"arrow_function" => Kind::Function,
"method_definition" => Kind::Function,
"generator_function_declaration" => Kind::Function,
"generator_function" => Kind::Function,
"switch_statement" => Kind::Block,
"switch_body" => Kind::Block,
"switch_case" => Kind::Block,
"switch_default" => Kind::Block,
"try_statement" => Kind::Block,
"catch_clause" => Kind::Block,
"finally_clause" => Kind::Block,
"class_declaration" => Kind::Block,
"class" => Kind::Block,
"class_body" => Kind::Block,
"export_statement" => Kind::Block,
// data-flow
"call_expression" => Kind::CallFn,

View file

@ -41,7 +41,6 @@ pub enum Kind {
InfiniteLoop,
While,
For,
LoopBody,
CallFn,
CallMethod,
CallMacro,
@ -196,7 +195,7 @@ pub fn lookup(lang: &str, raw: &str) -> Kind {
}
/// The kind of taint source, used to refine finding severity.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SourceKind {
/// Direct user input (request params, argv, stdin, form data)
UserInput,
@ -375,6 +374,11 @@ pub fn classify(lang: &str, text: &str, extra: Option<&[RuntimeLabelRule]>) -> O
let head = text.split(['(', '<']).next().unwrap_or("");
let trimmed = head.trim().as_bytes();
// For chained calls like `r.URL.Query().Get`, also strip internal
// `().` segments to produce a normalized form like `r.URL.Query.Get`.
let full_normalized = normalize_chained_call(text);
let full_norm_bytes = full_normalized.as_bytes();
// ── Check runtime (config) rules first — they take priority ──────
if let Some(extras) = extra {
// Pass 1: exact / suffix
@ -384,12 +388,8 @@ pub fn classify(lang: &str, text: &str, extra: Option<&[RuntimeLabelRule]>) -> O
if m.last() == Some(&b'_') {
continue;
}
if ends_with_ignore_case(trimmed, m) {
let start = trimmed.len() - m.len();
let ok = start == 0 || matches!(trimmed[start - 1], b'.' | b':');
if ok {
return Some(rule.label);
}
if match_suffix(trimmed, m) || match_suffix(full_norm_bytes, m) {
return Some(rule.label);
}
}
}
@ -397,7 +397,10 @@ pub fn classify(lang: &str, text: &str, extra: Option<&[RuntimeLabelRule]>) -> O
for rule in extras {
for raw in &rule.matchers {
let m = raw.as_bytes();
if m.last() == Some(&b'_') && starts_with_ignore_case(trimmed, m) {
if m.last() == Some(&b'_')
&& (starts_with_ignore_case(trimmed, m)
|| starts_with_ignore_case(full_norm_bytes, m))
{
return Some(rule.label);
}
}
@ -417,12 +420,8 @@ pub fn classify(lang: &str, text: &str, extra: Option<&[RuntimeLabelRule]>) -> O
if m.last() == Some(&b'_') {
continue;
}
if ends_with_ignore_case(trimmed, m) {
let start = trimmed.len() - m.len();
let ok = start == 0 || matches!(trimmed[start - 1], b'.' | b':');
if ok {
return Some(rule.label);
}
if match_suffix(trimmed, m) || match_suffix(full_norm_bytes, m) {
return Some(rule.label);
}
}
}
@ -431,7 +430,10 @@ pub fn classify(lang: &str, text: &str, extra: Option<&[RuntimeLabelRule]>) -> O
for rule in *rules {
for raw in rule.matchers {
let m = raw.as_bytes();
if m.last() == Some(&b'_') && starts_with_ignore_case(trimmed, m) {
if m.last() == Some(&b'_')
&& (starts_with_ignore_case(trimmed, m)
|| starts_with_ignore_case(full_norm_bytes, m))
{
return Some(rule.label);
}
}
@ -440,6 +442,58 @@ pub fn classify(lang: &str, text: &str, extra: Option<&[RuntimeLabelRule]>) -> O
None
}
/// Check if `text` ends with `matcher` at a word boundary (`.` or `:`).
#[inline]
fn match_suffix(text: &[u8], matcher: &[u8]) -> bool {
if ends_with_ignore_case(text, matcher) {
let start = text.len() - matcher.len();
start == 0 || matches!(text[start - 1], b'.' | b':')
} else {
false
}
}
/// Normalize a chained method call: strip `()` between `.` segments.
/// e.g. `r.URL.Query().Get` → `r.URL.Query.Get`
/// e.g. `r.URL.Query().Get("host")` → `r.URL.Query.Get`
fn normalize_chained_call(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'(' => {
// Skip from `(` to matching `)`, but only if followed by `.`
// This handles `Query().Get` → `Query.Get`
let mut depth = 1u32;
let mut j = i + 1;
while j < bytes.len() && depth > 0 {
if bytes[j] == b'(' {
depth += 1;
} else if bytes[j] == b')' {
depth -= 1;
}
j += 1;
}
// If we're at end or next char is `.`, skip the parens
if j >= bytes.len() || bytes[j] == b'.' {
i = j;
} else {
// Keep the paren content (unusual case)
result.push('(');
i += 1;
}
}
b'<' => break, // Stop at generic args
_ => {
result.push(bytes[i] as char);
i += 1;
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -3,8 +3,24 @@ use phf::{Map, phf_map};
pub static RULES: &[LabelRule] = &[
// ─────────── Sources ───────────
// Note: PHP `$` prefix is stripped by collect_idents, so match without `$`.
LabelRule {
matchers: &["$_GET", "$_POST", "$_REQUEST", "$_COOKIE"],
matchers: &[
"$_GET",
"_GET",
"$_POST",
"_POST",
"$_REQUEST",
"_REQUEST",
"$_COOKIE",
"_COOKIE",
"$_FILES",
"_FILES",
"$_SERVER",
"_SERVER",
"$_ENV",
"_ENV",
],
label: DataLabel::Source(Cap::all()),
},
LabelRule {
@ -20,17 +36,44 @@ pub static RULES: &[LabelRule] = &[
matchers: &["escapeshellarg", "escapeshellcmd"],
label: DataLabel::Sanitizer(Cap::SHELL_ESCAPE),
},
LabelRule {
matchers: &["basename"],
label: DataLabel::Sanitizer(Cap::FILE_IO),
},
// ─────────── Sinks ─────────────
LabelRule {
matchers: &["system", "exec", "passthru", "shell_exec"],
matchers: &[
"system",
"exec",
"passthru",
"shell_exec",
"proc_open",
"popen",
],
label: DataLabel::Sink(Cap::SHELL_ESCAPE),
},
LabelRule {
matchers: &["eval", "assert"],
label: DataLabel::Sink(Cap::SHELL_ESCAPE),
},
LabelRule {
matchers: &["include", "include_once", "require", "require_once"],
label: DataLabel::Sink(Cap::FILE_IO),
},
LabelRule {
matchers: &["unserialize"],
label: DataLabel::Sink(Cap::SHELL_ESCAPE),
},
LabelRule {
matchers: &["move_uploaded_file", "copy", "file_put_contents", "fwrite"],
label: DataLabel::Sink(Cap::FILE_IO),
},
LabelRule {
matchers: &["echo", "print"],
label: DataLabel::Sink(Cap::HTML_ESCAPE),
},
LabelRule {
matchers: &["mysqli_query", "pg_query"],
matchers: &["mysqli_query", "pg_query", "query"],
label: DataLabel::Sink(Cap::SHELL_ESCAPE),
},
];
@ -41,16 +84,29 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
"while_statement" => Kind::While,
"for_statement" => Kind::For,
"foreach_statement" => Kind::For,
"do_statement" => Kind::While,
"return_statement" => Kind::Return,
"throw_expression" => Kind::Return,
"break_statement" => Kind::Break,
"continue_statement" => Kind::Continue,
// structure
"program" => Kind::SourceFile,
"compound_statement" => Kind::Block,
"else_clause" => Kind::Block,
"else_if_clause" => Kind::Block,
"function_definition" => Kind::Function,
"method_declaration" => Kind::Function,
"switch_statement" => Kind::Block,
"switch_block" => Kind::Block,
"case_statement" => Kind::Block,
"default_statement" => Kind::Block,
"try_statement" => Kind::Block,
"catch_clause" => Kind::Block,
"finally_clause" => Kind::Block,
"colon_block" => Kind::Block,
"class_declaration" => Kind::Block,
// data-flow
"function_call_expression" => Kind::CallFn,

View file

@ -24,7 +24,7 @@ pub static RULES: &[LabelRule] = &[
},
LabelRule {
matchers: &["open"],
label: DataLabel::Source(Cap::all()),
label: DataLabel::Sink(Cap::FILE_IO),
},
LabelRule {
matchers: &[
@ -65,6 +65,14 @@ pub static RULES: &[LabelRule] = &[
matchers: &["cursor.execute", "cursor.executemany"],
label: DataLabel::Sink(Cap::SHELL_ESCAPE),
},
LabelRule {
matchers: &["send_file", "send_from_directory"],
label: DataLabel::Sink(Cap::FILE_IO),
},
LabelRule {
matchers: &["os.path.realpath"],
label: DataLabel::Sanitizer(Cap::FILE_IO),
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {
@ -74,13 +82,24 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
"for_statement" => Kind::For,
"return_statement" => Kind::Return,
"raise_statement" => Kind::Return,
"break_statement" => Kind::Break,
"continue_statement" => Kind::Continue,
// structure
"module" => Kind::SourceFile,
"block" => Kind::Block,
"else_clause" => Kind::Block,
"elif_clause" => Kind::Block,
"with_statement" => Kind::Block,
"function_definition" => Kind::Function,
"try_statement" => Kind::Block,
"except_clause" => Kind::Block,
"finally_clause" => Kind::Block,
"class_definition" => Kind::Block,
"decorated_definition" => Kind::Block,
"match_statement" => Kind::Block,
"case_clause" => Kind::Block,
// data-flow
"call" => Kind::CallFn,

View file

@ -40,6 +40,7 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
"if" => Kind::If,
"unless" => Kind::If,
"while" => Kind::While,
"until" => Kind::While,
"for" => Kind::For,
"return" => Kind::Return,
@ -49,15 +50,26 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
// structure
"program" => Kind::SourceFile,
"body_statement" => Kind::Block,
"do_block" => Kind::Block,
"do_block" => Kind::Function,
"then" => Kind::Block,
"else" => Kind::Block,
"elsif" => Kind::If,
"begin" => Kind::Block,
"rescue" => Kind::Block,
"ensure" => Kind::Block,
"case" => Kind::Block,
"when" => Kind::Block,
"class" => Kind::Block,
"module" => Kind::Block,
"do" => Kind::Block,
"block" => Kind::Function,
// data-flow
"call" => Kind::CallFn,
"method_call" => Kind::CallFn,
"assignment" => Kind::Assignment,
"method" => Kind::Function,
"singleton_method" => Kind::Function,
// trivia
"comment" => Kind::Trivia,

View file

@ -8,7 +8,7 @@ pub static RULES: &[LabelRule] = &[
label: DataLabel::Source(Cap::all()),
},
LabelRule {
matchers: &["fs::read_to_string", "source_file"],
matchers: &["source_file"],
label: DataLabel::Source(Cap::all()),
},
// ───────── Sanitizers ──────────
@ -36,17 +36,29 @@ pub static RULES: &[LabelRule] = &[
matchers: &["sink_html"],
label: DataLabel::Sink(Cap::HTML_ESCAPE),
},
LabelRule {
matchers: &[
"fs::read_to_string",
"fs::write",
"fs::read",
"File::open",
"File::create",
],
label: DataLabel::Sink(Cap::FILE_IO),
},
];
pub static KINDS: Map<&'static str, Kind> = phf_map! {
// control-flow
"if_expression" => Kind::If,
"loop_expression" => Kind::InfiniteLoop,
"loop_statement" => Kind::LoopBody,
"while_statement" => Kind::While,
"while_expression" => Kind::While,
"for_statement" => Kind::For,
"for_expression" => Kind::For,
"return_statement" => Kind::Return,
"return_expression" => Kind::Return,
"break_expression" => Kind::Break,
"break_statement" => Kind::Break,
"continue_expression" => Kind::Continue,
@ -55,7 +67,17 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
// structure
"source_file" => Kind::SourceFile,
"block" => Kind::Block,
"else_clause" => Kind::Block,
"match_expression" => Kind::Block,
"match_block" => Kind::Block,
"match_arm" => Kind::Block,
"unsafe_block" => Kind::Block,
"function_item" => Kind::Function,
"closure_expression" => Kind::Block,
"async_block" => Kind::Block,
"impl_item" => Kind::Block,
"trait_item" => Kind::Block,
"declaration_list" => Kind::Block,
// data-flow
"call_expression" => Kind::CallFn,

View file

@ -50,18 +50,36 @@ pub static KINDS: Map<&'static str, Kind> = phf_map! {
"while_statement" => Kind::While,
"for_statement" => Kind::For,
"for_in_statement" => Kind::For,
"for_of_statement" => Kind::For,
"do_statement" => Kind::While,
"return_statement" => Kind::Return,
"throw_statement" => Kind::Return,
"break_statement" => Kind::Break,
"continue_statement" => Kind::Continue,
// structure
"program" => Kind::SourceFile,
"statement_block" => Kind::Block,
"else_clause" => Kind::Block,
"function_declaration" => Kind::Function,
"function_expression" => Kind::Function,
"arrow_function" => Kind::Function,
"method_definition" => Kind::Function,
"generator_function_declaration" => Kind::Function,
"generator_function" => Kind::Function,
"switch_statement" => Kind::Block,
"switch_body" => Kind::Block,
"switch_case" => Kind::Block,
"switch_default" => Kind::Block,
"try_statement" => Kind::Block,
"catch_clause" => Kind::Block,
"finally_clause" => Kind::Block,
"class_declaration" => Kind::Block,
"class" => Kind::Block,
"class_body" => Kind::Block,
"abstract_class_declaration" => Kind::Block,
"export_statement" => Kind::Block,
"enum_declaration" => Kind::Trivia,
// data-flow
"call_expression" => Kind::CallFn,