pub mod checks;
pub mod config;
pub mod extract;
pub mod model;
pub mod sql_semantics;
use crate::commands::scan::Diag;
use crate::evidence::{Confidence, Evidence, SpanEvidence};
use crate::patterns::FindingCategory;
use crate::ssa::type_facts::TypeKind;
use crate::summary::GlobalSummaries;
use crate::symbol::{FuncKey, Lang, normalize_namespace};
use crate::utils::Config;
use std::collections::HashMap;
use std::path::Path;
use tree_sitter::Tree;
fn byte_offset_to_point(tree: &Tree, byte: usize) -> tree_sitter::Point {
tree.root_node()
.descendant_for_byte_range(byte, byte)
.map(|node| node.start_position())
.unwrap_or(tree_sitter::Point { row: 0, column: 0 })
}
pub type VarTypes = HashMap<String, TypeKind>;
#[allow(clippy::too_many_arguments)]
pub fn run_auth_analysis(
tree: &Tree,
source: &[u8],
lang: &str,
file_path: &Path,
cfg: &Config,
var_types: Option<&VarTypes>,
global_summaries: Option<&GlobalSummaries>,
scan_root: Option<&Path>,
) -> Vec<Diag> {
let rules = config::build_auth_rules(cfg, lang);
if !rules.enabled {
return Vec::new();
}
let mut model = extract::extract_authorization_model(
lang,
cfg.framework_ctx.as_ref(),
tree,
source,
file_path,
&rules,
);
if let Some(types) = var_types {
apply_var_types_to_model(&mut model, &rules, types);
apply_typed_bounded_params(&mut model, types);
}
apply_helper_lifting(&mut model, lang, file_path, scan_root, global_summaries);
if model.routes.is_empty() && model.units.is_empty() {
return Vec::new();
}
checks::run_checks(&model, &rules)
.into_iter()
.map(|finding| auth_finding_to_diag(&finding, tree, file_path))
.collect()
}
pub fn extract_auth_summaries_by_key(
tree: &Tree,
source: &[u8],
lang: &str,
file_path: &Path,
cfg: &Config,
scan_root: Option<&Path>,
) -> Vec<(FuncKey, model::AuthCheckSummary)> {
let rules = config::build_auth_rules(cfg, lang);
if !rules.enabled {
return Vec::new();
}
let model = extract::extract_authorization_model(
lang,
cfg.framework_ctx.as_ref(),
tree,
source,
file_path,
&rules,
);
summaries_keyed_by_func(&model, lang, file_path, scan_root)
}
fn summaries_keyed_by_func(
model: &model::AuthorizationModel,
lang: &str,
file_path: &Path,
scan_root: Option<&Path>,
) -> Vec<(FuncKey, model::AuthCheckSummary)> {
let Some(lang_enum) = Lang::from_slug(lang) else {
return Vec::new();
};
let path_str = file_path.to_string_lossy();
let root_str = scan_root.map(|r| r.to_string_lossy().into_owned());
let namespace = normalize_namespace(&path_str, root_str.as_deref());
let mut out = Vec::new();
for unit in &model.units {
let Some(name) = unit.name.as_deref() else {
continue;
};
if name.is_empty() {
continue;
}
let Some(summary) = build_unit_summary(unit) else {
continue;
};
let leaf = name.rsplit('.').next().unwrap_or(name).to_string();
let key = FuncKey {
lang: lang_enum,
namespace: namespace.clone(),
container: String::new(),
name: leaf,
arity: Some(unit.params.len()),
disambig: None,
kind: crate::symbol::FuncKind::Function,
};
out.push((key, summary));
}
out
}
fn build_unit_summary(unit: &model::AnalysisUnit) -> Option<model::AuthCheckSummary> {
use model::{AuthCheckKind, AuthCheckSummary};
if unit.params.is_empty() {
return None;
}
let mut summary = AuthCheckSummary::default();
for check in &unit.auth_checks {
if matches!(
check.kind,
AuthCheckKind::LoginGuard | AuthCheckKind::TokenExpiry | AuthCheckKind::TokenRecipient
) {
continue;
}
for subject in &check.subjects {
let Some(candidate) = subject_lift_key(subject) else {
continue;
};
if let Some(idx) = unit.params.iter().position(|p| p == &candidate) {
summary
.param_auth_kinds
.entry(idx)
.and_modify(|existing| {
*existing = stronger_check_kind(*existing, check.kind);
})
.or_insert(check.kind);
}
}
}
if summary.param_auth_kinds.is_empty() {
None
} else {
Some(summary)
}
}
fn apply_var_types_to_model(
model: &mut model::AuthorizationModel,
rules: &config::AuthAnalysisRules,
var_types: &VarTypes,
) {
for unit in &mut model.units {
for op in &mut unit.operations {
let Some(first) = receiver_root(&op.callee) else {
continue;
};
let Some(ty) = var_types.get(first) else {
continue;
};
if let Some(new_class) = sink_class_for_type(ty, &op.callee, rules) {
op.sink_class = Some(new_class);
}
}
}
}
fn apply_typed_bounded_params(model: &mut model::AuthorizationModel, var_types: &VarTypes) {
for unit in &mut model.units {
for name in &unit.params {
let Some(ty) = var_types.get(name) else {
continue;
};
match ty {
TypeKind::Int | TypeKind::Bool => {
unit.typed_bounded_vars.insert(name.clone());
}
TypeKind::Dto(dto) => {
let mut bounded = Vec::new();
for (field_name, field_kind) in &dto.fields {
if matches!(field_kind, TypeKind::Int | TypeKind::Bool) {
bounded.push(field_name.clone());
}
}
if !bounded.is_empty() {
unit.typed_bounded_dto_fields.insert(name.clone(), bounded);
}
}
_ => {}
}
}
}
}
fn receiver_root(callee: &str) -> Option<&str> {
let (first, rest) = callee.split_once('.')?;
if rest.is_empty() {
return None;
}
if first.is_empty() { None } else { Some(first) }
}
fn sink_class_for_type(
ty: &TypeKind,
callee: &str,
rules: &config::AuthAnalysisRules,
) -> Option<model::SinkClass> {
match ty {
TypeKind::LocalCollection => Some(model::SinkClass::InMemoryLocal),
TypeKind::HttpClient => Some(model::SinkClass::OutboundNetwork),
TypeKind::DatabaseConnection => {
if rules.is_read(callee) && !rules.is_mutation(callee) {
Some(model::SinkClass::DbCrossTenantRead)
} else {
Some(model::SinkClass::DbMutation)
}
}
_ => None,
}
}
fn apply_helper_lifting(
model: &mut model::AuthorizationModel,
lang: &str,
file_path: &Path,
scan_root: Option<&Path>,
global_summaries: Option<&GlobalSummaries>,
) {
use std::collections::HashSet;
let caller_lang = Lang::from_slug(lang);
let path_str = file_path.to_string_lossy();
let root_str = scan_root.map(|r| r.to_string_lossy().into_owned());
let caller_namespace = normalize_namespace(&path_str, root_str.as_deref());
const MAX_ROUNDS: usize = 4;
for _ in 0..MAX_ROUNDS {
let summaries = build_helper_summaries(model);
let have_same_file = !summaries.is_empty();
let have_cross_file =
global_summaries.is_some_and(|gs| gs.auth_by_key().is_some()) && caller_lang.is_some();
if !have_same_file && !have_cross_file {
return;
}
let mut added = false;
let synth: Vec<(usize, Vec<model::AuthCheck>)> = model
.units
.iter()
.enumerate()
.map(|(idx, unit)| {
let mut out = synthesise_checks_for_unit(unit, &summaries);
if have_cross_file
&& let (Some(gs), Some(lang_enum)) = (global_summaries, caller_lang)
{
out.extend(synthesise_cross_file_checks_for_unit(
unit,
&summaries,
gs,
lang_enum,
&caller_namespace,
));
}
(idx, out)
})
.collect();
let mut existing_keys_per_unit: Vec<HashSet<((usize, usize), model::AuthCheckKind)>> =
model
.units
.iter()
.map(|u| {
u.auth_checks
.iter()
.map(|c| (c.span, c.kind))
.collect::<HashSet<_>>()
})
.collect();
for (idx, checks) in synth {
for check in checks {
let key = (check.span, check.kind);
if existing_keys_per_unit[idx].insert(key) {
model.units[idx].auth_checks.push(check);
added = true;
}
}
}
if !added {
return;
}
}
}
fn build_helper_summaries(
model: &model::AuthorizationModel,
) -> std::collections::HashMap<String, model::AuthCheckSummary> {
use model::{AuthCheckKind, AuthCheckSummary};
use std::collections::HashMap;
let mut summaries: HashMap<String, AuthCheckSummary> = HashMap::new();
for unit in &model.units {
let Some(name) = unit.name.as_deref() else {
continue;
};
if name.is_empty() || unit.params.is_empty() {
continue;
}
let mut summary = AuthCheckSummary::default();
for check in &unit.auth_checks {
if matches!(
check.kind,
AuthCheckKind::LoginGuard
| AuthCheckKind::TokenExpiry
| AuthCheckKind::TokenRecipient
) {
continue;
}
for subject in &check.subjects {
let candidate = subject_lift_key(subject);
let Some(candidate) = candidate else { continue };
if let Some(idx) = unit.params.iter().position(|p| p == &candidate) {
summary
.param_auth_kinds
.entry(idx)
.and_modify(|existing| {
*existing = stronger_check_kind(*existing, check.kind);
})
.or_insert(check.kind);
}
}
}
if !summary.param_auth_kinds.is_empty() {
let last = name.rsplit('.').next().unwrap_or(name).to_string();
summaries
.entry(last)
.or_default()
.param_auth_kinds
.extend(summary.param_auth_kinds);
}
}
summaries
}
fn subject_lift_key(subject: &model::ValueRef) -> Option<String> {
if let Some(base) = subject.base.as_deref() {
let first = base.split('.').next().unwrap_or(base).trim();
if !first.is_empty() {
return Some(first.to_string());
}
}
if subject.name.is_empty() {
None
} else {
Some(
subject
.name
.split('.')
.next()
.unwrap_or(&subject.name)
.to_string(),
)
}
}
fn stronger_check_kind(a: model::AuthCheckKind, b: model::AuthCheckKind) -> model::AuthCheckKind {
use model::AuthCheckKind::*;
fn rank(k: model::AuthCheckKind) -> u8 {
match k {
Ownership => 5,
Membership => 4,
AdminGuard => 3,
Other => 2,
LoginGuard => 1,
TokenExpiry | TokenRecipient => 0,
}
}
if rank(a) >= rank(b) { a } else { b }
}
fn synthesise_checks_for_unit(
unit: &model::AnalysisUnit,
summaries: &std::collections::HashMap<String, model::AuthCheckSummary>,
) -> Vec<model::AuthCheck> {
let line_of = |span: (usize, usize)| -> usize {
let _ = span;
unit.line
};
let mut out = Vec::new();
for call in &unit.call_sites {
let last = call.name.rsplit('.').next().unwrap_or(&call.name);
let Some(summary) = summaries.get(last) else {
continue;
};
if unit.name.as_deref() == Some(last) {
continue;
}
let mut subjects: Vec<model::ValueRef> = Vec::new();
let mut effective_kind = model::AuthCheckKind::Other;
for (param_idx, kind) in &summary.param_auth_kinds {
let Some(arg_refs) = call.args_value_refs.get(*param_idx) else {
continue;
};
subjects.extend(arg_refs.iter().cloned());
effective_kind = stronger_check_kind(effective_kind, *kind);
}
if subjects.is_empty() {
continue;
}
let line = call_site_line(unit, call).unwrap_or_else(|| line_of(call.span));
out.push(model::AuthCheck {
kind: effective_kind,
callee: format!("(lifted {})", call.name),
subjects,
span: call.span,
line,
args: call.args.clone(),
condition_text: None,
});
}
out
}
fn call_site_line(unit: &model::AnalysisUnit, call: &model::CallSite) -> Option<usize> {
for op in &unit.operations {
if op.span.0 == call.span.0 {
return Some(op.line);
}
}
None
}
fn synthesise_cross_file_checks_for_unit(
unit: &model::AnalysisUnit,
same_file_summaries: &std::collections::HashMap<String, model::AuthCheckSummary>,
gs: &GlobalSummaries,
caller_lang: Lang,
caller_namespace: &str,
) -> Vec<model::AuthCheck> {
let mut out = Vec::new();
for call in &unit.call_sites {
let last = call.name.rsplit('.').next().unwrap_or(&call.name);
if unit.name.as_deref() == Some(last) {
continue;
}
if same_file_summaries.contains_key(last) {
continue;
}
let arity_hint = Some(call.args.len());
let key = match gs.resolve_callee_key(last, caller_lang, caller_namespace, arity_hint) {
crate::summary::CalleeResolution::Resolved(key) => key,
_ => continue,
};
let mut canonical = key.clone();
canonical.disambig = None;
canonical.container = String::new();
canonical.kind = crate::symbol::FuncKind::Function;
let Some(summary) = gs.get_auth(&canonical) else {
continue;
};
let mut subjects: Vec<model::ValueRef> = Vec::new();
let mut effective_kind = model::AuthCheckKind::Other;
for (param_idx, kind) in &summary.param_auth_kinds {
let Some(arg_refs) = call.args_value_refs.get(*param_idx) else {
continue;
};
subjects.extend(arg_refs.iter().cloned());
effective_kind = stronger_check_kind(effective_kind, *kind);
}
if subjects.is_empty() {
continue;
}
let line = call_site_line(unit, call).unwrap_or(unit.line);
out.push(model::AuthCheck {
kind: effective_kind,
callee: format!("(lifted cross-file {})", call.name),
subjects,
span: call.span,
line,
args: call.args.clone(),
condition_text: None,
});
}
out
}
fn auth_finding_to_diag(finding: &checks::AuthFinding, tree: &Tree, file_path: &Path) -> Diag {
let point = byte_offset_to_point(tree, finding.span.0);
Diag {
path: file_path.to_string_lossy().into_owned(),
line: point.row + 1,
col: point.column + 1,
severity: finding.severity,
id: finding.rule_id.clone(),
category: FindingCategory::Security,
path_validated: false,
guard_kind: None,
message: Some(finding.message.clone()),
labels: vec![],
confidence: Some(Confidence::Medium),
evidence: Some(Evidence {
source: None,
sink: Some(SpanEvidence {
path: file_path.to_string_lossy().into_owned(),
line: (point.row + 1) as u32,
col: (point.column + 1) as u32,
kind: "sink".into(),
snippet: None,
}),
guards: vec![],
sanitizers: vec![],
state: None,
notes: vec![],
..Default::default()
}),
rank_score: None,
rank_reason: None,
suppressed: false,
suppression: None,
rollup: None,
finding_id: String::new(),
alternative_finding_ids: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::{VarTypes, apply_var_types_to_model, receiver_root, sink_class_for_type};
use crate::auth_analysis::config::build_auth_rules;
use crate::auth_analysis::model::{
AnalysisUnit, AnalysisUnitKind, AuthorizationModel, OperationKind, SensitiveOperation,
SinkClass,
};
use crate::ssa::type_facts::TypeKind;
use crate::utils::config::Config;
use std::collections::{HashMap, HashSet};
fn sample_op(callee: &str, initial: Option<SinkClass>) -> SensitiveOperation {
SensitiveOperation {
kind: OperationKind::Mutation,
sink_class: initial,
callee: callee.to_string(),
subjects: Vec::new(),
span: (0, 0),
line: 1,
text: callee.to_string(),
}
}
fn sample_unit(op: SensitiveOperation) -> AnalysisUnit {
AnalysisUnit {
kind: AnalysisUnitKind::Function,
name: Some("handle".into()),
span: (0, 0),
params: Vec::new(),
context_inputs: Vec::new(),
call_sites: Vec::new(),
auth_checks: Vec::new(),
operations: vec![op],
value_refs: Vec::new(),
condition_texts: Vec::new(),
line: 1,
row_field_vars: HashMap::new(),
var_alias_chain: HashMap::new(),
row_population_data: HashMap::new(),
self_actor_vars: HashSet::new(),
self_actor_id_vars: HashSet::new(),
authorized_sql_vars: HashSet::new(),
const_bound_vars: HashSet::new(),
typed_bounded_vars: HashSet::new(),
typed_bounded_dto_fields: HashMap::new(),
self_scoped_session_bases: HashSet::new(),
}
}
#[test]
fn receiver_root_returns_first_segment_only_for_chain_calls() {
assert_eq!(receiver_root("map.insert"), Some("map"));
assert_eq!(receiver_root("self.cache.insert"), Some("self"));
assert_eq!(receiver_root("HashMap"), None);
assert_eq!(receiver_root("free_fn"), None);
assert_eq!(receiver_root("."), None);
assert_eq!(receiver_root(""), None);
}
#[test]
fn sink_class_for_type_maps_security_typekinds() {
let cfg = Config::default();
let rules = build_auth_rules(&cfg, "rust");
assert_eq!(
sink_class_for_type(&TypeKind::LocalCollection, "whatever.insert", &rules),
Some(SinkClass::InMemoryLocal)
);
assert_eq!(
sink_class_for_type(&TypeKind::HttpClient, "client.send", &rules),
Some(SinkClass::OutboundNetwork)
);
assert_eq!(
sink_class_for_type(&TypeKind::DatabaseConnection, "conn.insert", &rules),
Some(SinkClass::DbMutation)
);
assert_eq!(
sink_class_for_type(&TypeKind::DatabaseConnection, "conn.get", &rules),
Some(SinkClass::DbCrossTenantRead)
);
assert_eq!(
sink_class_for_type(&TypeKind::DatabaseConnection, "conn.execute", &rules),
Some(SinkClass::DbMutation)
);
assert_eq!(
sink_class_for_type(&TypeKind::String, "s.len", &rules),
None
);
assert_eq!(
sink_class_for_type(&TypeKind::Unknown, "x.frobnicate", &rules),
None
);
}
#[test]
fn apply_var_types_overrides_sink_class_for_known_receiver() {
let cfg = Config::default();
let rules = build_auth_rules(&cfg, "rust");
let mut model = AuthorizationModel::default();
model.units.push(sample_unit(sample_op(
"results.insert",
Some(SinkClass::DbMutation),
)));
let mut var_types: VarTypes = HashMap::new();
var_types.insert("results".into(), TypeKind::LocalCollection);
apply_var_types_to_model(&mut model, &rules, &var_types);
assert_eq!(
model.units[0].operations[0].sink_class,
Some(SinkClass::InMemoryLocal)
);
}
#[test]
fn apply_var_types_leaves_classification_untouched_when_receiver_unknown() {
let cfg = Config::default();
let rules = build_auth_rules(&cfg, "rust");
let mut model = AuthorizationModel::default();
model.units.push(sample_unit(sample_op(
"db.insert",
Some(SinkClass::DbMutation),
)));
let var_types: VarTypes = HashMap::new();
apply_var_types_to_model(&mut model, &rules, &var_types);
assert_eq!(
model.units[0].operations[0].sink_class,
Some(SinkClass::DbMutation)
);
}
}