use crate::deps::{build_inventory, DependencyInventory};
use crate::finding::{
compute_finding_id, AlternateTaintFlow, Finding, FindingMatch, FindingStatus, TaintPropagationArg,
TaintPropagationStep, TaintedArgInfo,
};
use crate::loader::{Rulepack, RulepackMetadata};
use crate::matcher::{
callback_extension_attribution_match, configured_receiver_factory_attribution_match,
dedup_inventory_matches, infer_entry_point_sources_for_files_with_progress,
match_rules_against_facts_for_inventory_with_progress_on_files,
match_rules_against_facts_for_sink_inventory_with_progress_on_files,
match_rules_against_facts_for_taint_support_with_progress_on_files,
match_rules_against_facts_for_taint_with_progress_on_files,
match_rules_against_facts_with_progress_on_files, rule_match_passes_constraints_with_taint_view,
rule_target_matches_call, InterTaintView, RuleConstraintTaintContext, RuleMatch, RuntimeDisabledRule,
};
use crate::rule::{
ConstraintKind, ContextFlowRole, FlowClass, GuardProfile, MatchKind, MatchOrigin, NonTaintEvaluation,
PathContainmentGuardSemantics, PostSinkPolicy, RelativePathContainmentGuardSemantics, Rule, RuleKind,
RuleTarget, SanitizerAttachmentPolicy, SanitizerGuardSemantics, Severity, SourceCallbackArgSemantics,
};
use crate::sanitizer_credit::{sanitizer_credits_sink_tag, sanitizer_tag_is_recognized_non_crediting};
use ahash::{AHashMap, AHashSet};
use anyhow::Result;
use bonsai_common::{
path_filter_matches_with_root, workspace_relative_filter_path, FileId, FuncId, Precision, Span, SymbolId,
};
use bonsai_index::GlobalIndex;
use bonsai_lang_api::{
branch_condition_fact_for_span, AssignValueKind, BranchConditionFact, BranchConditionPolarity,
ConditionEquality, ConditionExpressionFact, ConditionOperandFact, DeclKind, FlowEvent, LanguageRegistry,
StringCompositionPart,
};
use bonsai_taint::{
compose_idg_seed_nodes, CallResultPassthrough, CleanOutputOverwrite, EntryTaintGraph, IdgSeedRequest,
InterTaintCaches, InterTaintConfig, OutputArgFlow, ReceiverStatePropagation, SourceCallbackArgs,
SourceOutputArgs, TaintedCall, TaintedCallEdge, TokenSet,
};
use bonsai_workspace::Workspace;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::sync::{mpsc, Arc, OnceLock};
use std::time::{Duration, Instant};
mod chain_executor;
mod clean_overwrite;
mod execution;
mod findings_build;
mod guard_sanitizers;
mod prototype_guard;
mod source_seeds;
mod taint_cache;
mod validation;
use clean_overwrite::{
call_arg_target_keys, clean_overwrite_callee_tail, clean_overwrite_target_key,
interprocedural_clean_overwrite_kills_lineage_arg, same_function_clean_overwrite_kills_sink_arg,
tainted_arg_info_from_events, tainted_arg_target_keys, CleanOverwritePolicy,
};
#[cfg(test)]
use clean_overwrite::{clean_output_call_overwrites_target, try_region_clean_overwrites_target};
use execution::{
append_taint_target_key, append_taint_target_node_key, build_findings_chain_aware,
effective_source_seed_key, sorted_seed_key_with_anchor, source_analysis_lineage_func_scope,
source_analysis_worker_count, source_can_precede_sink, ChainAnalysisRequest, ScheduledSourceGroup,
SourceGroupExecutor, SourceWorkItem,
};
use findings_build::{
build_pattern_only_findings, make_finding, rule_has_taint_predicate, rule_is_non_taint_sink,
rule_is_pattern_only_finding, FindingBuildContext,
};
use guard_sanitizers::{
character_constraint_sanitizer, character_escape_sanitizer, collect_compiler_call_sites_reaching_value,
compiler_guard_sanitizer, configured_argument_factory_guard_sanitizer,
configured_argument_receiver_guard_sanitizer, configured_call_argument_guard_sanitizer,
finite_literal_selection_sanitizer, nosql_eq_filter_wrapper_sanitizer,
parameterized_query_guard_sanitizer, path_consumer_containment_guard_sanitizer,
path_containment_guard_sanitizer, place_is_assigned_between, receiver_configuration_guard_sanitizer,
receiver_factory_guard_sanitizer, relative_path_containment_guard_sanitizer,
runtime_type_rejection_guard_sanitizer, same_origin_path_constraint_sanitizer,
sanitized_context_rewrite_covers_consumer, source_sink_pair_is_low_signal,
terminal_rejection_predicate_guard_span, url_network_guard_sanitizer, url_reconstruction_guard_sanitizer,
CompilerGuardContext,
};
use prototype_guard::prototype_pollution_sink_is_guarded;
#[cfg(test)]
use source_seeds::seed_descendant_aliases_for_qualified_source_reads;
use source_seeds::{
collect_source_seed_targets, insert_descendant_taint_aliases, insert_taint_aliases,
security_text_matches_source_strict,
};
pub use validation::validate_pack;
#[cfg(test)]
use validation::{lowercase_receiver_token_from_regex, regex_prefix_is_receiver_agnostic};
type SourceMatchDedupeKey = (String, String, u64, u64, String);
type SourceMatchDedupeValue<'a> = (usize, &'a RuleMatch, FuncId, u64);
pub(crate) const PUBLIC_SEMANTIC_MAX_PRECISION: Precision = Precision::Narrowed;
#[derive(Clone, Debug)]
pub enum AnalysisProgress {
PhaseStarted {
label: &'static str,
total: u64,
},
PhaseTicked,
PhaseFinished,
Note {
label: &'static str,
detail: String,
},
}
#[derive(Clone, Debug)]
pub struct TaintAnalysisOptions {
pub source: Option<String>,
pub flow_id: Option<String>,
pub trust: Option<String>,
pub category: Option<String>,
pub sink: Option<String>,
pub severity: Option<Severity>,
pub tag: Option<String>,
pub files: Vec<String>,
pub exclude_files: Vec<String>,
pub include_inferred_sources: bool,
pub include_pattern_only: bool,
pub show_sanitized: bool,
pub max_precision: Option<Precision>,
pub exclude_tests: bool,
pub attach_flow_evidence: bool,
pub taint_graph_resident_cache_entries: Option<usize>,
}
impl Default for TaintAnalysisOptions {
fn default() -> Self {
Self {
source: None,
flow_id: None,
trust: None,
category: None,
sink: None,
severity: None,
tag: None,
files: Vec::new(),
exclude_files: Vec::new(),
include_inferred_sources: false,
include_pattern_only: false,
show_sanitized: false,
max_precision: Some(PUBLIC_SEMANTIC_MAX_PRECISION),
exclude_tests: false,
attach_flow_evidence: true,
taint_graph_resident_cache_entries: None,
}
}
}
impl TaintAnalysisOptions {
#[must_use]
pub fn semantic_precision_only(mut self) -> Self {
self.max_precision = Some(match self.max_precision {
Some(Precision::Exact) => Precision::Exact,
_ => PUBLIC_SEMANTIC_MAX_PRECISION,
});
self
}
}
#[derive(Clone, Debug, Default)]
pub struct SourceAnalysisOptions {
pub source: Option<String>,
pub trust: Option<String>,
pub category: Option<String>,
pub tag: Option<String>,
pub files: Vec<String>,
pub exclude_files: Vec<String>,
pub exclude_tests: bool,
pub include_inferred_sources: bool,
pub lineage_limits: SourceLineageLimits,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SourceLineageLimits {
pub max_hops: usize,
pub max_paths: usize,
}
impl SourceLineageLimits {
#[must_use]
pub const fn bounded_default() -> Self {
Self {
max_hops: SOURCE_ANALYSIS_LINEAGE_RENDER_HOPS,
max_paths: SOURCE_ANALYSIS_LINEAGE_RENDER_PATHS,
}
}
#[must_use]
pub const fn unbounded() -> Self {
Self {
max_hops: usize::MAX,
max_paths: usize::MAX,
}
}
}
impl Default for SourceLineageLimits {
fn default() -> Self {
Self::bounded_default()
}
}
#[derive(Clone, Debug, Default)]
pub struct SecurityInventoryOptions {
pub rule: Option<String>,
pub rule_regex: Option<String>,
pub trust: Option<String>,
pub category: Option<String>,
pub severity: Option<Severity>,
pub tag: Option<String>,
pub files: Vec<String>,
pub exclude_files: Vec<String>,
}
#[derive(Clone, Debug, Default)]
pub struct DependencyInventoryOptions {
pub framework: Option<String>,
pub severity: Option<Severity>,
pub files: Vec<String>,
pub exclude_files: Vec<String>,
}
#[derive(Clone, Debug, Default)]
pub struct PackInventoryOptions {
pub lang: Option<String>,
pub category: Option<String>,
pub kind: Option<RuleKind>,
pub severity: Option<Severity>,
pub taint_replay_examples: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct SecurityMatchRow {
pub rule_id: String,
pub tag: Option<String>,
pub severity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trust: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub cwe: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub owasp: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub frameworks: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub packages: Vec<String>,
pub language: String,
pub file: String,
pub line: u32,
pub column: u32,
pub text: String,
pub enclosing_fn: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PackRuleRow {
pub rule_id: String,
pub language: String,
pub kind: String,
pub family: String,
pub tag: Option<String>,
pub severity: Option<String>,
pub enabled: bool,
pub packages: Vec<String>,
pub frameworks: Vec<String>,
pub description: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct PackAuditReport {
pub canonical_sink_families: Vec<String>,
pub sink_family_short_labels: AHashMap<String, String>,
pub languages: Vec<PackAuditLanguage>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PackAuditLanguage {
pub language: String,
pub sinks: BTreeMap<String, PackAuditFamilyCount>,
pub sources: PackAuditCount,
pub sanitizers: PackAuditCount,
}
#[derive(Clone, Debug, Serialize)]
pub struct PackAuditFamilyCount {
pub enabled: u32,
pub disabled: u32,
pub not_applicable: bool,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PackAuditCount {
pub enabled: u32,
pub disabled: u32,
}
#[derive(Clone, Debug, Serialize)]
pub struct PackTreeReport {
pub languages: Vec<PackTreeLanguage>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PackTreeLanguage {
pub language: String,
pub kinds: BTreeMap<String, Vec<PackTreeFile>>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PackTreeFile {
pub file: String,
pub rules: Vec<PackTreeRule>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PackTreeRule {
pub id: String,
pub severity: Option<String>,
pub enabled: bool,
pub tag: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PackValidationReport {
pub valid: bool,
pub rule_count: usize,
pub enabled_rule_count: usize,
pub disabled_rule_count: usize,
pub disabled_waiting_reenable_count: usize,
pub disabled_reason_counts: BTreeMap<String, usize>,
pub example_count: usize,
pub enabled_example_count: usize,
pub errors: usize,
pub warnings: usize,
pub issues: Vec<PackValidationIssue>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PackValidationIssue {
pub level: &'static str,
pub code: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub message: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FindingWithChain {
#[serde(flatten)]
pub finding: Finding,
#[serde(skip)]
pub chain_funcs: Vec<FuncId>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CombinedFindingWithChain {
#[serde(flatten)]
pub finding: Finding,
#[serde(skip)]
pub chain_funcs: Vec<FuncId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub additional_sources: Vec<FindingMatch>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub additional_sinks: Vec<FindingMatch>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub member_finding_ids: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TaintAnalysisReport {
pub findings: Vec<CombinedFindingWithChain>,
pub source_rule_count: usize,
pub sink_rule_count: usize,
pub sanitizer_rule_count: usize,
#[serde(default)]
pub analysis_complete: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub analysis_incomplete_reasons: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub runtime_disabled_rules: Vec<RuntimeDisabledRule>,
}
impl TaintAnalysisReport {
#[must_use]
pub fn severity_counts(&self) -> (usize, usize, usize) {
let critical = self
.findings
.iter()
.filter(|combined| combined.finding.severity == Some(Severity::Critical))
.count();
let high = self
.findings
.iter()
.filter(|combined| combined.finding.severity == Some(Severity::High))
.count();
let medium = self
.findings
.iter()
.filter(|combined| combined.finding.severity == Some(Severity::Medium))
.count();
(critical, high, medium)
}
}
#[derive(Clone, Debug, Default)]
struct ResolutionCoverage {
unresolved_workspace_sites: AHashSet<(FuncId, Span)>,
}
impl ResolutionCoverage {
fn from_graph(
graph: &bonsai_callgraph::ResolvedCallGraph,
analyzed_funcs: impl IntoIterator<Item = FuncId>,
) -> Self {
let analyzed_funcs: AHashSet<FuncId> = analyzed_funcs.into_iter().collect();
Self {
unresolved_workspace_sites: graph
.unresolved_workspace_call_sites()
.filter(|(caller, _)| analyzed_funcs.contains(caller))
.collect(),
}
}
}
fn workspace_analysis_incomplete_reasons(
ws: &Workspace,
scan_files: &[FileId],
resolution: Option<&ResolutionCoverage>,
) -> Vec<String> {
let mut reasons: BTreeSet<String> = ws
.parser_incomplete_reasons_for_files(scan_files)
.into_iter()
.collect();
if let Some(resolution) = resolution {
let unresolved_workspace_calls = resolution.unresolved_workspace_sites.len();
if unresolved_workspace_calls > 0 {
reasons.insert(format!(
"unresolved-workspace-call-sites:{unresolved_workspace_calls}"
));
}
}
reasons.into_iter().collect()
}
fn taint_analysis_incomplete_reasons(
ws: &Workspace,
scan_files: &[FileId],
findings: &[CombinedFindingWithChain],
resolution: Option<&ResolutionCoverage>,
) -> Vec<String> {
let mut reasons: BTreeSet<String> = workspace_analysis_incomplete_reasons(ws, scan_files, resolution)
.into_iter()
.collect();
let incomplete_findings = findings
.iter()
.filter(|combined| !combined.finding.analysis_complete)
.count();
if incomplete_findings > 0 {
reasons.insert(format!("incomplete-finding-evidence:{incomplete_findings}"));
for reason in findings
.iter()
.flat_map(|combined| combined.finding.analysis_incomplete_reasons.iter())
{
reasons.insert(format!("finding-evidence:{reason}"));
}
}
reasons.into_iter().collect()
}
#[derive(Clone, Debug)]
pub struct SourceAnalysisCandidate {
pub source: FindingMatch,
pub path: Vec<FuncId>,
pub flow_id: String,
pub chain_names: Vec<String>,
pub taint_path: Vec<TaintPropagationStep>,
pub precision: Precision,
pub lineage: SourceLineageStatus,
}
#[derive(Clone, Debug, Serialize)]
pub struct CombinedSourceAnalysisCandidate {
pub source: FindingMatch,
pub chain_names: Vec<String>,
pub path: Vec<FuncId>,
pub flow_id: String,
pub taint_path: Vec<TaintPropagationStep>,
pub precision: Precision,
pub lineage: SourceLineageStatus,
pub additional_sources: Vec<FindingMatch>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct SourceLineageStatus {
pub complete: bool,
pub truncated_hops: bool,
pub omitted_paths: usize,
pub emitted_paths: usize,
pub max_hops: usize,
pub max_paths: usize,
}
impl SourceLineageStatus {
fn complete() -> Self {
Self {
complete: true,
truncated_hops: false,
omitted_paths: 0,
emitted_paths: 0,
max_hops: SOURCE_ANALYSIS_LINEAGE_RENDER_HOPS,
max_paths: SOURCE_ANALYSIS_LINEAGE_RENDER_PATHS,
}
}
fn from_lineage(
emission: &SourceLineageEmission<'_>,
stats: SourceLineageEnumeration,
emitted_index: usize,
) -> Self {
let omitted_paths = if emitted_index == 0 {
stats.omitted_paths
} else {
0
};
let incomplete = emission.truncated_hops || omitted_paths > 0;
Self {
complete: !incomplete,
truncated_hops: emission.truncated_hops,
omitted_paths,
emitted_paths: 1,
max_hops: stats.max_hops,
max_paths: stats.max_paths,
}
}
pub fn is_complete_default(&self) -> bool {
self.complete && !self.truncated_hops && self.omitted_paths == 0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct SourceLineageSummary {
pub complete: bool,
pub incomplete_flows: usize,
pub truncated_hop_flows: usize,
pub omitted_paths: usize,
pub emitted_paths: usize,
pub max_hops: usize,
pub max_paths: usize,
}
impl Default for SourceLineageSummary {
fn default() -> Self {
Self {
complete: true,
incomplete_flows: 0,
truncated_hop_flows: 0,
omitted_paths: 0,
emitted_paths: 0,
max_hops: SOURCE_ANALYSIS_LINEAGE_RENDER_HOPS,
max_paths: SOURCE_ANALYSIS_LINEAGE_RENDER_PATHS,
}
}
}
impl SourceLineageSummary {
pub fn from_candidates(candidates: &[CombinedSourceAnalysisCandidate]) -> Self {
Self::from_statuses(candidates.iter().map(|candidate| candidate.lineage))
}
pub fn is_complete(&self) -> bool {
self.complete
&& self.incomplete_flows == 0
&& self.truncated_hop_flows == 0
&& self.omitted_paths == 0
}
fn from_statuses<I>(statuses: I) -> Self
where
I: IntoIterator<Item = SourceLineageStatus>,
{
let mut summary = Self::default();
for status in statuses {
if !status.is_complete_default() {
summary.incomplete_flows = summary.incomplete_flows.saturating_add(1);
}
if status.truncated_hops {
summary.truncated_hop_flows = summary.truncated_hop_flows.saturating_add(1);
}
summary.omitted_paths = summary.omitted_paths.saturating_add(status.omitted_paths);
summary.emitted_paths = summary.emitted_paths.saturating_add(status.emitted_paths);
summary.max_hops = summary.max_hops.max(status.max_hops);
summary.max_paths = summary.max_paths.max(status.max_paths);
}
summary.complete = summary.incomplete_flows == 0;
summary
}
}
#[derive(Clone, Debug)]
pub struct SourceAnalysisReport {
pub candidates: Vec<CombinedSourceAnalysisCandidate>,
pub source_rule_count: usize,
pub lineage_summary: SourceLineageSummary,
pub analysis_complete: bool,
pub analysis_incomplete_reasons: Vec<String>,
pub runtime_disabled_rules: Vec<RuntimeDisabledRule>,
}
const SOURCE_ANALYSIS_LINEAGE_RENDER_HOPS: usize = 6;
const SOURCE_ANALYSIS_LINEAGE_RENDER_PATHS: usize = 24;
struct SelectedTaintRules<'a> {
sources: Vec<&'a Rule>,
sinks: Vec<&'a Rule>,
sanitizers: Vec<&'a Rule>,
sink_rule_count: usize,
rulepack_typing: Arc<crate::matcher::RulepackTyping>,
}
fn select_taint_analysis_rules<'a>(
ws: &Workspace,
pack: &'a Rulepack,
options: &TaintAnalysisOptions,
) -> Result<SelectedTaintRules<'a>> {
let mut sources = select_rules(pack, RuleKind::Source, None, options.source.as_deref(), |rule| {
source_rule_matches_filters(rule, options.trust.as_deref(), options.category.as_deref(), None)
})?;
let mut sinks = select_rules(pack, RuleKind::Sink, None, options.sink.as_deref(), |rule| {
options
.severity
.is_none_or(|minimum| rule.severity.is_some_and(|severity| severity >= minimum))
&& options
.tag
.as_deref()
.is_none_or(|tag| rule.tag.as_deref() == Some(tag))
})?;
let mut sanitizers = select_rules(pack, RuleKind::Sanitizer, None, None, |_| true)?;
sources.retain(|rule| rule.returns_type.is_none());
sinks.retain(|rule| rule.returns_type.is_none());
sanitizers.retain(|rule| rule.returns_type.is_none());
filter_rules_to_workspace_languages(ws, &mut sources);
filter_rules_to_workspace_languages(ws, &mut sinks);
filter_rules_to_workspace_languages(ws, &mut sanitizers);
Ok(SelectedTaintRules {
sink_rule_count: sinks.len(),
rulepack_typing: crate::matcher::build_rulepack_typing(&pack.all_rules()),
sources,
sinks,
sanitizers,
})
}
struct TaintFindingFinalization<'a> {
ws: &'a Workspace,
sink_hits: &'a [RuleMatch],
pattern_sink_hits: &'a [RuleMatch],
pack: &'a Rulepack,
options: &'a TaintAnalysisOptions,
}
fn finalize_taint_findings<F>(
mut findings_raw: Vec<FindingWithChain>,
request: TaintFindingFinalization<'_>,
on_progress: &mut F,
) -> Vec<CombinedFindingWithChain>
where
F: FnMut(AnalysisProgress),
{
let TaintFindingFinalization {
ws,
sink_hits,
pattern_sink_hits,
pack,
options,
} = request;
extend_implicit_context_findings(&mut findings_raw, sink_hits, pack, ws);
on_progress(AnalysisProgress::PhaseStarted {
label: "finalizing findings",
total: 0,
});
let taint_sink_sites: AHashSet<(String, String, u32, u32)> = findings_raw
.iter()
.map(|finding| {
(
finding.finding.sink.rule_id.clone(),
finding.finding.sink.file.clone(),
finding.finding.sink.line,
finding.finding.sink.column,
)
})
.collect();
findings_raw.extend(build_pattern_only_findings(
ws,
pattern_sink_hits,
pack,
&taint_sink_sites,
));
findings_raw.sort_by(|a, b| {
let af = &a.finding;
let bf = &b.finding;
af.source
.rule_id
.cmp(&bf.source.rule_id)
.then_with(|| af.sink.rule_id.cmp(&bf.sink.rule_id))
.then_with(|| af.sink.file.cmp(&bf.sink.file))
.then_with(|| af.sink.line.cmp(&bf.sink.line))
.then_with(|| af.sink.column.cmp(&bf.sink.column))
.then_with(|| af.finding_id.cmp(&bf.finding_id))
});
if let Some(flow_id) = options.flow_id.as_deref() {
findings_raw.retain(|item| item.finding.representative_flow_id.as_deref() == Some(flow_id));
}
if let Some(max_precision) = options.max_precision {
findings_raw.retain(|item| finding_precision_within(&item.finding.precision, max_precision));
}
if !options.exclude_files.is_empty() || options.exclude_tests {
findings_raw.retain(|item| {
!finding_has_excluded_path(
ws,
&item.finding,
&options.exclude_files,
options.exclude_tests,
&pack.metadata.test_path_patterns,
)
});
}
if !options.show_sanitized {
findings_raw.retain(|item| item.finding.status != FindingStatus::Sanitized);
}
let mut route_findings = findings_raw
.into_iter()
.map(combined_from_raw_finding)
.collect::<Vec<_>>();
drop_rulepack_terminal_dominated_findings(&mut route_findings, pack, Some(ws));
drop_dominated_wrapper_findings(&mut route_findings, ws);
drop_dominated_receiver_projection_findings(&mut route_findings);
route_findings = drop_field_mismatched_inferred_findings(route_findings);
let mut findings = combine_route_findings_by_sink(route_findings, pack);
findings.sort_by(|a, b| {
b.finding
.severity
.cmp(&a.finding.severity)
.then_with(|| a.finding.sink.rule_id.cmp(&b.finding.sink.rule_id))
.then_with(|| a.finding.sink.file.cmp(&b.finding.sink.file))
.then_with(|| a.finding.sink.line.cmp(&b.finding.sink.line))
.then_with(|| a.finding.sink.column.cmp(&b.finding.sink.column))
.then_with(|| {
source_reporting_rank(pack, &a.finding.source)
.cmp(&source_reporting_rank(pack, &b.finding.source))
})
.then_with(|| a.finding.finding_id.cmp(&b.finding.finding_id))
});
on_progress(AnalysisProgress::PhaseFinished);
findings
}
fn hydrate_taint_flow_evidence<F>(
ws: &Workspace,
findings: &mut [CombinedFindingWithChain],
attach: bool,
on_progress: &mut F,
) where
F: FnMut(AnalysisProgress),
{
if attach {
on_progress(AnalysisProgress::PhaseStarted {
label: "attaching flow evidence",
total: findings.len() as u64,
});
let mut flow_body_cache = crate::flow_evidence::FlowBodyCache::new(ws);
for combined in findings.iter_mut() {
combined.finding.hops = flow_body_cache.build_flow_bodies(
&combined.chain_funcs,
&combined.finding.source,
&combined.finding.taint_path,
crate::flow_evidence::FlowRole::Sink,
);
on_progress(AnalysisProgress::PhaseTicked);
}
on_progress(AnalysisProgress::PhaseFinished);
} else {
on_progress(AnalysisProgress::PhaseStarted {
label: "skipping bulk flow evidence",
total: 0,
});
on_progress(AnalysisProgress::PhaseFinished);
}
}
struct TaintReportCompletion<'a> {
ws: &'a Workspace,
scan_files: &'a [FileId],
resolution: Option<&'a ResolutionCoverage>,
unattributed_source_matches: usize,
unattributed_sink_matches: usize,
source_rule_count: usize,
sink_rule_count: usize,
sanitizer_rule_count: usize,
}
fn finish_taint_analysis_report(
findings: Vec<CombinedFindingWithChain>,
completion: TaintReportCompletion<'_>,
) -> TaintAnalysisReport {
let TaintReportCompletion {
ws,
scan_files,
resolution,
unattributed_source_matches,
unattributed_sink_matches,
source_rule_count,
sink_rule_count,
sanitizer_rule_count,
} = completion;
let runtime_disabled_rules = crate::matcher::drain_runtime_disabled_rules();
let mut analysis_incomplete_reasons: BTreeSet<String> =
taint_analysis_incomplete_reasons(ws, scan_files, &findings, resolution)
.into_iter()
.collect();
if unattributed_source_matches > 0 {
analysis_incomplete_reasons.insert(format!(
"unattributed-source-matches:{unattributed_source_matches}"
));
}
if unattributed_sink_matches > 0 {
analysis_incomplete_reasons.insert(format!("unattributed-sink-matches:{unattributed_sink_matches}"));
}
if !runtime_disabled_rules.is_empty() {
analysis_incomplete_reasons
.insert(format!("runtime-disabled-rules:{}", runtime_disabled_rules.len()));
}
let analysis_incomplete_reasons: Vec<String> = analysis_incomplete_reasons.into_iter().collect();
TaintAnalysisReport {
findings,
source_rule_count,
sink_rule_count,
sanitizer_rule_count,
analysis_complete: analysis_incomplete_reasons.is_empty(),
analysis_incomplete_reasons,
runtime_disabled_rules,
}
}
fn finish_taint_cache_write_through<F>(ws: &Workspace, persist_started: bool, on_progress: &mut F)
where
F: FnMut(AnalysisProgress),
{
if !persist_started {
return;
}
let detail = taint_cache::finish_workspace_cache(ws).map_or_else(
|| "finish write-through failed".to_string(),
|written| format!("finish write-through entries={written}"),
);
on_progress(AnalysisProgress::Note {
label: "taint-cache",
detail,
});
}
fn begin_dependency_package_snapshot(
ws: &Workspace,
pack: &Rulepack,
) -> Option<crate::deps::WorkspaceDependencyPackageSnapshot> {
ws.db().workspace_root().map(|root| {
crate::deps::begin_workspace_dependency_package_snapshot(&root, ws.db().vfs().instance_id(), pack)
})
}
pub fn run_taint_analysis(
ws: &Workspace,
pack: &Rulepack,
options: TaintAnalysisOptions,
) -> Result<TaintAnalysisReport> {
run_taint_analysis_with_phase_progress(ws, pack, options, |_| {})
}
pub fn run_taint_analysis_with_progress<F>(
ws: &Workspace,
pack: &Rulepack,
options: TaintAnalysisOptions,
mut on_match_file_done: F,
) -> Result<TaintAnalysisReport>
where
F: FnMut(&'static str),
{
let mut current_label: Option<&'static str> = None;
run_taint_analysis_with_phase_progress(ws, pack, options, |event| match event {
AnalysisProgress::PhaseStarted { label, .. } => current_label = Some(label),
AnalysisProgress::PhaseTicked => {
if let Some(label) = current_label {
on_match_file_done(label);
}
}
AnalysisProgress::PhaseFinished => current_label = None,
AnalysisProgress::Note { .. } => {}
})
}
pub fn run_taint_analysis_with_phase_progress<F>(
ws: &Workspace,
pack: &Rulepack,
options: TaintAnalysisOptions,
mut on_progress: F,
) -> Result<TaintAnalysisReport>
where
F: FnMut(AnalysisProgress),
{
let _taint_analysis_guard = ws.lock_taint_analysis();
let _dependency_package_snapshot = begin_dependency_package_snapshot(ws, pack);
let _ = crate::matcher::drain_runtime_disabled_rules();
ws.release_idg_service_cache();
ws.release_resolved_call_graph_cache();
ws.release_exact_body_cache();
ws.db().release_global_index();
let options = options.semantic_precision_only();
let SelectedTaintRules {
sources,
mut sinks,
mut sanitizers,
sink_rule_count: selected_sink_rule_count,
rulepack_typing,
} = select_taint_analysis_rules(ws, pack, &options)?;
let selected_sanitizer_rule_count = sanitizers.len();
let scan_files = security_scan_files(
ws,
&options.files,
&options.exclude_files,
options.exclude_tests,
&pack.metadata.test_path_patterns,
);
let total_files = scan_files.len() as u64;
on_progress(AnalysisProgress::Note {
label: "scope",
detail: format!(
"taint-analysis files={} source_rules={} sink_rules={} sanitizer_rules={} include_inferred_sources={} exclude_tests={} file_filters={} exclude_filters={}",
scan_files.len(),
sources.len(),
sinks.len(),
sanitizers.len(),
options.include_inferred_sources,
options.exclude_tests,
options.files.len(),
options.exclude_files.len()
),
});
let mut source_hits = gather_taint_support_matches_phased(
ws,
&sources,
"matching source rules",
&scan_files,
total_files,
&rulepack_typing,
&mut on_progress,
);
if options.include_inferred_sources && options.source.is_none() {
let concrete_param_bases = concrete_source_param_bases(pack, &source_hits);
on_progress(AnalysisProgress::PhaseStarted {
label: "inferring entry-point sources",
total: total_files,
});
let inferred_sources = infer_entry_point_sources_for_files_with_progress(ws, &scan_files, || {
on_progress(AnalysisProgress::PhaseTicked);
});
on_progress(AnalysisProgress::PhaseFinished);
source_hits.extend(
inferred_sources
.into_iter()
.filter(|inferred| !inferred_param_subsumed_by_concrete(inferred, &concrete_param_bases)),
);
}
filter_source_hits_by_metadata(
&mut source_hits,
pack,
options.trust.as_deref(),
options.category.as_deref(),
None,
);
filter_by_path(ws, &mut source_hits, &options.files, &options.exclude_files);
let include_pattern_only = options.include_pattern_only
&& options.source.is_none()
&& options.trust.is_none()
&& options.category.is_none();
let non_taint_sink_ids: AHashSet<String> = sinks
.iter()
.copied()
.filter(|rule| rule_is_non_taint_sink(rule))
.map(|rule| rule.id.clone())
.collect();
let pattern_only_sink_ids: AHashSet<String> = sinks
.iter()
.copied()
.filter(|rule| rule_is_pattern_only_finding(rule))
.map(|rule| rule.id.clone())
.collect();
let pattern_sinks: Vec<&Rule> = if include_pattern_only {
sinks
.iter()
.copied()
.filter(|rule| pattern_only_sink_ids.contains(&rule.id))
.collect()
} else {
Vec::new()
};
let source_languages: AHashSet<&str> = source_hits.iter().map(|hit| hit.language.as_str()).collect();
sinks.retain(|rule| {
!non_taint_sink_ids.contains(&rule.id) && source_languages.contains(rule.language.as_str())
});
sanitizers.retain(|rule| source_languages.contains(rule.language.as_str()));
let endpoint_scan_files = scan_files.clone();
let endpoint_total_files = endpoint_scan_files.len() as u64;
on_progress(AnalysisProgress::Note {
label: "scope",
detail: format!(
"taint-analysis source_matches={} endpoint_files={} source_languages={} static_evidence={}",
source_hits.len(),
endpoint_scan_files.len(),
source_languages.len(),
static_evidence_label(options.max_precision)
),
});
on_progress(AnalysisProgress::PhaseStarted {
label: "matching sink rules",
total: endpoint_total_files,
});
let mut sink_hits = match_rules_against_facts_for_taint_with_progress_on_files(
ws,
&sinks,
&endpoint_scan_files,
&rulepack_typing,
|| {
on_progress(AnalysisProgress::PhaseTicked);
},
);
on_progress(AnalysisProgress::PhaseFinished);
let mut sanitizer_hits = gather_taint_support_matches_phased(
ws,
&sanitizers,
"matching sanitizer rules",
&endpoint_scan_files,
endpoint_total_files,
&rulepack_typing,
&mut on_progress,
);
filter_by_path(ws, &mut sink_hits, &options.files, &options.exclude_files);
filter_by_path(ws, &mut sanitizer_hits, &options.files, &options.exclude_files);
on_progress(AnalysisProgress::Note {
label: "scope",
detail: format!(
"taint-analysis sink_matches={} sanitizer_matches={} pattern_sinks={}",
sink_hits.len(),
sanitizer_hits.len(),
pattern_sinks.len()
),
});
let mut pattern_sink_hits = if pattern_sinks.is_empty() {
Vec::new()
} else {
gather_matches_phased(
ws,
&pattern_sinks,
"matching pattern sink rules",
&endpoint_scan_files,
endpoint_total_files,
&rulepack_typing,
&mut on_progress,
)
};
filter_by_path(ws, &mut pattern_sink_hits, &options.files, &options.exclude_files);
if options.exclude_tests {
let root = ws.db().workspace_root();
let test_patterns = &pack.metadata.test_path_patterns;
source_hits
.retain(|m| !path_is_excluded_with_root(root.as_deref(), &m.file, &[], true, test_patterns));
sink_hits.retain(|m| !path_is_excluded_with_root(root.as_deref(), &m.file, &[], true, test_patterns));
pattern_sink_hits
.retain(|m| !path_is_excluded_with_root(root.as_deref(), &m.file, &[], true, test_patterns));
}
sort_matches(&mut source_hits);
sort_matches(&mut sink_hits);
sort_matches(&mut sanitizer_hits);
sort_matches(&mut pattern_sink_hits);
let has_endpoint_pair = !source_hits.is_empty() && !sink_hits.is_empty();
let unattributed_source_matches = if has_endpoint_pair {
source_hits
.iter()
.filter(|source| func_id_for_match(ws, source).is_none())
.count()
} else {
0
};
let unattributed_sink_matches = if has_endpoint_pair {
sink_hits
.iter()
.filter(|sink| func_id_for_match(ws, sink).is_none())
.count()
} else {
0
};
let chain_build = build_findings_chain_aware(ChainAnalysisRequest {
ws,
source_hits: &source_hits,
sinks: &sink_hits,
sanitizers: &sanitizer_hits,
pack,
max_precision: options.max_precision,
taint_graph_resident_cache_entries: options.taint_graph_resident_cache_entries,
rulepack_typing: &rulepack_typing,
on_progress: &mut on_progress,
});
let mut findings = finalize_taint_findings(
chain_build.findings,
TaintFindingFinalization {
ws,
sink_hits: &sink_hits,
pattern_sink_hits: &pattern_sink_hits,
pack,
options: &options,
},
&mut on_progress,
);
hydrate_taint_flow_evidence(ws, &mut findings, options.attach_flow_evidence, &mut on_progress);
Ok(finish_taint_analysis_report(
findings,
TaintReportCompletion {
ws,
scan_files: &scan_files,
resolution: chain_build.resolution.as_ref(),
unattributed_source_matches,
unattributed_sink_matches,
source_rule_count: sources.len(),
sink_rule_count: selected_sink_rule_count,
sanitizer_rule_count: selected_sanitizer_rule_count,
},
))
}
pub fn run_source_analysis(
ws: &Workspace,
pack: &Rulepack,
options: SourceAnalysisOptions,
) -> Result<SourceAnalysisReport> {
run_source_analysis_with_progress(ws, pack, options, |_| {})
}
pub fn run_source_analysis_with_progress<F>(
ws: &Workspace,
pack: &Rulepack,
options: SourceAnalysisOptions,
mut on_match_file_done: F,
) -> Result<SourceAnalysisReport>
where
F: FnMut(&'static str),
{
let mut current_label: Option<&'static str> = None;
run_source_analysis_with_phase_progress(ws, pack, options, |event| match event {
AnalysisProgress::PhaseStarted { label, .. } => current_label = Some(label),
AnalysisProgress::PhaseTicked => {
if let Some(label) = current_label {
on_match_file_done(label);
}
}
AnalysisProgress::PhaseFinished => current_label = None,
AnalysisProgress::Note { .. } => {}
})
}
struct SourceGraphJob {
source_match: FindingMatch,
start: FuncId,
seeds: TokenSet,
anchor: Option<Span>,
output_arg_names: Vec<String>,
graph_key: Vec<String>,
}
struct SourceGraphGroup {
first_index: usize,
start: FuncId,
graph_key: Vec<String>,
lineage_funcs: Option<AHashSet<FuncId>>,
jobs: Vec<SourceGraphJob>,
}
struct SourceHitForFunction<'a> {
index: usize,
hit: &'a RuleMatch,
source_match: FindingMatch,
}
fn schedule_source_graph_groups(
ws: &Workspace,
pack: &Rulepack,
global: &GlobalIndex,
source_hits: &[RuleMatch],
) -> (Vec<SourceGraphGroup>, usize) {
let mut hits_by_func: AHashMap<FuncId, Vec<SourceHitForFunction<'_>>> = AHashMap::new();
for (index, hit) in source_hits.iter().enumerate() {
let Some(source_match) = source_finding_match(hit, pack) else {
continue;
};
let Some(start) = func_id_for_match(ws, hit) else {
continue;
};
hits_by_func.entry(start).or_default().push(SourceHitForFunction {
index,
hit,
source_match,
});
}
let mut hits_by_func: Vec<_> = hits_by_func.into_iter().collect();
hits_by_func.sort_by_key(|(func, hits)| {
(
global
.declaring_file(SymbolId::new(func.raw()))
.map_or(u32::MAX, FileId::raw),
hits.first().map(|hit| hit.index).unwrap_or(usize::MAX),
)
});
let source_function_count = hits_by_func.len();
let mut source_jobs = Vec::new();
let mut active_file = None;
let mut active_index = None;
for (start, hits) in hits_by_func {
let Some(file) = global.declaring_file(SymbolId::new(start.raw())) else {
continue;
};
if active_file != Some(file) {
active_index = ws.exact_decl_index_shared(file);
active_file = Some(file);
}
let Some(decl) = active_index
.as_ref()
.and_then(|index| index.defs.iter().find(|decl| decl.symbol.raw() == start.raw()))
else {
continue;
};
for hit in hits {
let seeds = source_seed_set(pack, hit.hit, decl);
let output_arg_names = output_arg_names_for_match(pack, hit.hit, decl);
let anchor = source_anchor_for_rule_match(pack, hit.hit);
let graph_key = sorted_seed_key_with_anchor(&seeds, anchor, &output_arg_names);
source_jobs.push((
hit.index,
SourceGraphJob {
source_match: hit.source_match,
start,
seeds,
anchor,
output_arg_names,
graph_key,
},
));
}
}
source_jobs.sort_by_key(|(index, _)| *index);
let mut source_groups: Vec<SourceGraphGroup> = Vec::new();
let mut group_by_key: AHashMap<(FuncId, Vec<String>), usize> = AHashMap::new();
for (index, job) in source_jobs {
let group_key = (job.start, job.graph_key.clone());
if let Some(&group_index) = group_by_key.get(&group_key) {
source_groups[group_index].jobs.push(job);
} else {
let group_index = source_groups.len();
group_by_key.insert(group_key, group_index);
source_groups.push(SourceGraphGroup {
first_index: index,
start: job.start,
graph_key: job.graph_key.clone(),
lineage_funcs: None,
jobs: vec![job],
});
}
}
source_groups.sort_by_key(|group| group.first_index);
(source_groups, source_function_count)
}
struct SourceLineageCompilationContext<'a> {
ws: &'a Workspace,
pack: &'a Rulepack,
global: &'a GlobalIndex,
transfer_languages: &'a AHashSet<String>,
graph_config: &'a InterTaintConfig,
transfer_options: &'a bonsai_idg::TransferOptions,
caches: &'a InterTaintCaches,
}
struct SourceLineageScope {
graph: SourceLineageGraph,
cache_persist_started: bool,
}
enum SourceLineageGraph {
Empty,
Compiled {
idg: Arc<bonsai_idg::IdgQueryService>,
resolution: ResolutionCoverage,
},
}
impl SourceLineageScope {
fn resolution(&self) -> Option<&ResolutionCoverage> {
match &self.graph {
SourceLineageGraph::Empty => None,
SourceLineageGraph::Compiled { resolution, .. } => Some(resolution),
}
}
}
fn compile_source_lineage_scope<F>(
context: &SourceLineageCompilationContext<'_>,
source_groups: &mut [SourceGraphGroup],
on_progress: &mut F,
) -> SourceLineageScope
where
F: FnMut(AnalysisProgress),
{
if source_groups.is_empty() {
let fingerprint = taint_cache::scoped_config_fingerprint(
context.pack,
"source-analysis",
context.graph_config.max_edge_precision,
&[],
&[],
context.transfer_options.semantic_fingerprint(),
);
let cache_report = taint_cache::prepare_workspace_cache(context.ws, "source-analysis", fingerprint);
on_progress(AnalysisProgress::Note {
label: "taint-cache",
detail: cache_report.detail(),
});
return SourceLineageScope {
graph: SourceLineageGraph::Empty,
cache_persist_started: cache_report.persist_started,
};
}
on_progress(AnalysisProgress::PhaseStarted {
label: "building source lineage scope",
total: source_groups.len() as u64 + 2,
});
let mut source_starts: Vec<FuncId> = source_groups.iter().map(|group| group.start).collect();
source_starts.sort_by_key(|func| func.raw());
source_starts.dedup();
let reachable_call_graph = context.ws.source_reachable_resolved_call_graph(
&source_starts,
&[],
context.graph_config.max_edge_precision,
);
let source_call_graph = reachable_call_graph.graph;
let resolution = ResolutionCoverage::from_graph(source_call_graph.as_ref(), reachable_call_graph.funcs);
context
.caches
.seed_resolved_call_graph(source_call_graph.as_ref());
context.ws.release_resolved_call_graph_cache();
on_progress(AnalysisProgress::PhaseTicked);
let mut scoped_func_set: AHashSet<FuncId> = AHashSet::default();
let mut lineage_scope_by_start: AHashMap<FuncId, AHashSet<FuncId>> = AHashMap::default();
for group in source_groups {
let source_lineage_funcs = lineage_scope_by_start
.entry(group.start)
.or_insert_with(|| {
source_analysis_lineage_func_scope(
group.start,
context.global,
source_call_graph.as_ref(),
context.graph_config.max_edge_precision,
)
})
.clone();
append_taint_target_key(
&mut group.graph_key,
"source_lineage",
Some(&source_lineage_funcs),
);
scoped_func_set.extend(source_lineage_funcs.iter().copied());
group.lineage_funcs = Some(source_lineage_funcs);
on_progress(AnalysisProgress::PhaseTicked);
}
let mut scoped_funcs: Vec<FuncId> = scoped_func_set.into_iter().collect();
scoped_funcs.sort_by_key(|func| func.raw());
scoped_funcs.dedup();
let mut scoped_files: Vec<FileId> = scoped_funcs
.iter()
.filter_map(|func| context.global.declaring_file(SymbolId::new(func.raw())))
.collect();
scoped_files.sort_by_key(|file| file.raw());
scoped_files.dedup();
let fingerprint = taint_cache::scoped_config_fingerprint(
context.pack,
"source-analysis",
context.graph_config.max_edge_precision,
&scoped_files,
&scoped_funcs,
context.transfer_options.semantic_fingerprint(),
);
let cache_report = taint_cache::prepare_workspace_cache(context.ws, "source-analysis", fingerprint);
on_progress(AnalysisProgress::Note {
label: "taint-cache",
detail: cache_report.detail(),
});
let idg = seed_idg_service_for_rulepack_for_files(
context.ws,
context.pack,
context.transfer_languages,
&context.graph_config.receiver_state_propagations,
&scoped_files,
&scoped_funcs,
source_call_graph.as_ref(),
);
on_progress(AnalysisProgress::PhaseTicked);
on_progress(AnalysisProgress::PhaseFinished);
SourceLineageScope {
graph: SourceLineageGraph::Compiled { idg, resolution },
cache_persist_started: cache_report.persist_started,
}
}
struct SourceLineageEnumerationContext<'a> {
ws: &'a Workspace,
global: &'a bonsai_index::GlobalIndex,
idg: &'a bonsai_idg::IdgQueryService,
graph_config: &'a InterTaintConfig,
caches: &'a InterTaintCaches,
lineage_limits: SourceLineageLimits,
}
fn build_source_group_candidates(
context: &SourceLineageEnumerationContext<'_>,
group: &SourceGraphGroup,
) -> Vec<SourceAnalysisCandidate> {
let graph = context
.ws
.taint_index()
.get(group.start, &group.graph_key)
.unwrap_or_else(|| {
let first = &group.jobs[0];
let graph = Arc::new(bonsai_taint::entry_taint_call_records_from_idg_query(
bonsai_taint::IdgTaintQuery::semantic(
bonsai_taint::IdgTaintSource::rule_match(
group.start,
&first.seeds,
first.anchor,
&first.output_arg_names,
),
context.ws.db(),
context.idg,
)
.with_global_index(context.global)
.with_transfers(bonsai_taint::IdgTaintTransfers {
call_result_passthroughs: &context.graph_config.call_result_passthroughs,
call_results_materialized: true,
..bonsai_taint::IdgTaintTransfers::none()
})
.with_targets(bonsai_taint::IdgTaintTargets {
nodes: None,
funcs: group.lineage_funcs.as_ref(),
lineage_funcs: group.lineage_funcs.as_ref(),
relevance: None,
})
.with_max_precision(context.graph_config.max_edge_precision)
.with_caches(context.caches),
));
context
.ws
.taint_index()
.insert_if_absent(group.start, group.graph_key.clone(), graph)
});
let mut candidates = Vec::new();
for job in &group.jobs {
let mut seen_chains: AHashSet<Vec<String>> = AHashSet::new();
let (lineages, lineage_stats) = collect_tainted_source_lineages(
&graph.call_records,
job.start,
context.lineage_limits.max_hops,
context.lineage_limits.max_paths,
);
let mut emitted_lineage_rows = 0usize;
for emission in &lineages {
let terminal = emission
.records
.last()
.map(|record| record.callee)
.unwrap_or(job.start);
let Some(path) = chain_funcs_for_lineage(&emission.records, job.start, terminal) else {
continue;
};
let Some(chain_names) = chain_names_for_path(context.ws, context.global, &path) else {
continue;
};
if !seen_chains.insert(chain_names.clone()) {
continue;
}
let taint_path = taint_path_for_lineage(context.ws, context.global, &emission.records, None);
let flow_id = flow_id_for_taint_path(&chain_names, &taint_path);
let precision = chain_precision_for_records(&emission.records);
if !precision.is_semantic() {
continue;
}
candidates.push(SourceAnalysisCandidate {
source: job.source_match.clone(),
path,
flow_id,
chain_names,
taint_path,
precision,
lineage: SourceLineageStatus::from_lineage(emission, lineage_stats, emitted_lineage_rows),
});
emitted_lineage_rows = emitted_lineage_rows.saturating_add(1);
}
if lineages.is_empty() {
let path = vec![job.start];
let Some(chain_names) = chain_names_for_path(context.ws, context.global, &path) else {
continue;
};
let taint_path = Vec::new();
let flow_id = flow_id_for_taint_path(&chain_names, &taint_path);
candidates.push(SourceAnalysisCandidate {
source: job.source_match.clone(),
path,
flow_id,
chain_names,
taint_path,
precision: Precision::Exact,
lineage: SourceLineageStatus::complete(),
});
}
}
candidates
}
fn canonicalize_source_candidates(
parallel_candidates: Vec<SourceAnalysisCandidate>,
) -> Vec<SourceAnalysisCandidate> {
let mut seen: AHashMap<(String, String, u32, u32, String), usize> = AHashMap::new();
let mut candidates: Vec<SourceAnalysisCandidate> = Vec::with_capacity(parallel_candidates.len());
for candidate in parallel_candidates {
let dedupe_key = (
candidate.source.rule_id.clone(),
candidate.source.file.clone(),
candidate.source.line,
candidate.source.column,
displayed_chain_key(&candidate.chain_names),
);
if let Some(&index) = seen.get(&dedupe_key) {
merge_source_lineage_status(&mut candidates[index].lineage, candidate.lineage);
candidates[index].precision = candidates[index].precision.meet(candidate.precision);
} else {
let index = candidates.len();
seen.insert(dedupe_key, index);
candidates.push(candidate);
}
}
candidates
}
fn enumerate_source_candidates<F>(
context: &SourceLineageEnumerationContext<'_>,
source_groups: &[SourceGraphGroup],
total_source_path_ticks: usize,
on_progress: &mut F,
) -> Vec<SourceAnalysisCandidate>
where
F: FnMut(AnalysisProgress),
{
on_progress(AnalysisProgress::PhaseStarted {
label: "enumerating source paths",
total: total_source_path_ticks as u64,
});
use rayon::prelude::*;
let worker_count = source_analysis_worker_count();
let mut grouped_candidates: Vec<(usize, Vec<SourceAnalysisCandidate>)> =
if worker_count > 1 && source_groups.len() > 1 {
match rayon::ThreadPoolBuilder::new().num_threads(worker_count).build() {
Ok(pool) => {
let (tx, rx) = mpsc::channel();
let mut groups = None;
std::thread::scope(|scope| {
let worker = scope.spawn(|| {
pool.install(|| {
source_groups
.par_iter()
.enumerate()
.map(|(index, group)| {
let candidates = build_source_group_candidates(context, group);
let _ = tx.send(group.jobs.len());
(index, candidates)
})
.collect()
})
});
let mut completed = 0usize;
while completed < total_source_path_ticks {
match rx.recv_timeout(Duration::from_millis(250)) {
Ok(ticks) => {
for _ in 0..ticks {
completed = completed.saturating_add(1);
on_progress(AnalysisProgress::PhaseTicked);
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if worker.is_finished() {
break;
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
groups = Some(match worker.join() {
Ok(result) => result,
Err(payload) => std::panic::resume_unwind(payload),
});
while completed < total_source_path_ticks {
on_progress(AnalysisProgress::PhaseTicked);
completed += 1;
}
});
groups.unwrap_or_default()
}
Err(_) => source_groups
.iter()
.enumerate()
.map(|(index, group)| {
let candidates = build_source_group_candidates(context, group);
for _ in 0..group.jobs.len() {
on_progress(AnalysisProgress::PhaseTicked);
}
(index, candidates)
})
.collect(),
}
} else {
source_groups
.iter()
.enumerate()
.map(|(index, group)| {
let candidates = build_source_group_candidates(context, group);
for _ in 0..group.jobs.len() {
on_progress(AnalysisProgress::PhaseTicked);
}
(index, candidates)
})
.collect()
};
grouped_candidates.sort_by_key(|(index, _)| *index);
let parallel_candidates = grouped_candidates
.into_iter()
.flat_map(|(_, candidates)| candidates)
.collect();
let candidates = canonicalize_source_candidates(parallel_candidates);
let emitted_ticks: usize = source_groups.iter().map(|group| group.jobs.len()).sum();
for _ in emitted_ticks..total_source_path_ticks {
on_progress(AnalysisProgress::PhaseTicked);
}
on_progress(AnalysisProgress::PhaseFinished);
candidates
}
pub fn run_source_analysis_with_phase_progress<F>(
ws: &Workspace,
pack: &Rulepack,
options: SourceAnalysisOptions,
mut on_progress: F,
) -> Result<SourceAnalysisReport>
where
F: FnMut(AnalysisProgress),
{
let _taint_analysis_guard = ws.lock_taint_analysis();
let _dependency_package_snapshot = begin_dependency_package_snapshot(ws, pack);
let _ = crate::matcher::drain_runtime_disabled_rules();
let mut sources = select_rules(pack, RuleKind::Source, None, options.source.as_deref(), |r| {
source_rule_matches_filters(
r,
options.trust.as_deref(),
options.category.as_deref(),
options.tag.as_deref(),
)
})?;
filter_rules_to_workspace_languages(ws, &mut sources);
let rulepack_typing = crate::matcher::build_rulepack_typing(&pack.all_rules());
let scan_files = security_scan_files(
ws,
&options.files,
&options.exclude_files,
options.exclude_tests,
&pack.metadata.test_path_patterns,
);
let total_files = scan_files.len() as u64;
on_progress(AnalysisProgress::Note {
label: "scope",
detail: format!(
"source-analysis files={} source_rules={} include_inferred_sources={} exclude_tests={} file_filters={} exclude_filters={}",
scan_files.len(),
sources.len(),
options.include_inferred_sources,
options.exclude_tests,
options.files.len(),
options.exclude_files.len()
),
});
let mut source_hits = gather_matches_phased(
ws,
&sources,
"matching source rules",
&scan_files,
total_files,
&rulepack_typing,
&mut on_progress,
);
if options.include_inferred_sources {
let concrete_param_bases = concrete_source_param_bases(pack, &source_hits);
on_progress(AnalysisProgress::PhaseStarted {
label: "inferring entry-point sources",
total: total_files,
});
let inferred_sources = infer_entry_point_sources_for_files_with_progress(ws, &scan_files, || {
on_progress(AnalysisProgress::PhaseTicked);
});
on_progress(AnalysisProgress::PhaseFinished);
source_hits.extend(
inferred_sources
.into_iter()
.filter(|inferred| !inferred_param_subsumed_by_concrete(inferred, &concrete_param_bases)),
);
}
if let Some(source) = options.source.as_deref() {
let source_re = Regex::new(source)
.map_err(|error| anyhow::anyhow!("invalid source regex `{source}`: {error}"))?;
source_hits.retain(|hit| {
source_re.is_match(&hit.rule_id)
|| pack
.find_rule_by_id(&hit.rule_id)
.is_some_and(|rule| rule.aliases.iter().any(|alias| source_re.is_match(alias)))
});
}
filter_source_hits_by_metadata(
&mut source_hits,
pack,
options.trust.as_deref(),
options.category.as_deref(),
options.tag.as_deref(),
);
filter_by_path(ws, &mut source_hits, &options.files, &options.exclude_files);
if options.exclude_tests {
let root = ws.db().workspace_root();
source_hits.retain(|m| {
!path_is_test_file_with_root(root.as_deref(), &m.file, &pack.metadata.test_path_patterns)
});
}
sort_matches(&mut source_hits);
let unattributed_source_matches = source_hits
.iter()
.filter(|source| func_id_for_match(ws, source).is_none())
.count();
on_progress(AnalysisProgress::Note {
label: "scope",
detail: format!("source-analysis source_matches={}", source_hits.len()),
});
let global = ws.compiler_linkage_index();
let transfer_languages = workspace_languages(ws);
on_progress(AnalysisProgress::PhaseStarted {
label: "compiling transfer sites",
total: scan_files.len() as u64,
});
let receiver_state_propagations = compiled_receiver_state_propagations_for_languages(
ws,
pack,
&transfer_languages,
&rulepack_typing,
Some(&scan_files),
|| on_progress(AnalysisProgress::PhaseTicked),
);
on_progress(AnalysisProgress::PhaseFinished);
let source_graph_config = InterTaintConfig {
clean_output_overwrites: clean_output_overwrites_from_rulepack_for_languages(
pack,
&transfer_languages,
),
source_output_args: source_output_args_from_rulepack_for_languages(pack, &transfer_languages),
source_callback_args: source_callback_args_from_rulepack_for_languages(pack, &transfer_languages),
call_result_passthroughs: call_result_passthroughs_from_rulepack_for_languages(
pack,
&transfer_languages,
),
output_arg_flows: output_arg_flows_from_rulepack_for_languages(pack, &transfer_languages),
receiver_state_propagations,
max_edge_precision: Some(Precision::Narrowed),
};
let source_graph_caches = ws.inter_taint_caches();
let mut source_idg_transfer_options = idg_transfer_options_from_rulepack_shapes(
&source_graph_config.clean_output_overwrites,
&source_graph_config.source_output_args,
&source_graph_config.source_callback_args,
&source_graph_config.output_arg_flows,
&source_graph_config.receiver_state_propagations,
);
source_idg_transfer_options.call_result_passthroughs =
idg_call_result_passthrough_specs(&source_graph_config.call_result_passthroughs);
let (mut source_groups, source_function_count) =
schedule_source_graph_groups(ws, pack, global.as_ref(), &source_hits);
on_progress(AnalysisProgress::Note {
label: "scope",
detail: format!(
"source-analysis source_jobs={} source_graph_groups={} functions={}",
source_groups.iter().map(|group| group.jobs.len()).sum::<usize>(),
source_groups.len(),
source_function_count
),
});
crate::matcher::release_matcher_fact_caches();
ws.release_idg_service_cache();
ws.release_compiler_header_cache();
ws.release_exact_body_cache();
ws.db().release_global_index();
let source_scope = compile_source_lineage_scope(
&SourceLineageCompilationContext {
ws,
pack,
global: global.as_ref(),
transfer_languages: &transfer_languages,
graph_config: &source_graph_config,
transfer_options: &source_idg_transfer_options,
caches: source_graph_caches,
},
&mut source_groups,
&mut on_progress,
);
let mut candidates = match &source_scope.graph {
SourceLineageGraph::Empty => {
debug_assert!(source_groups.is_empty());
on_progress(AnalysisProgress::PhaseStarted {
label: "enumerating source paths",
total: source_hits.len() as u64,
});
for _ in 0..source_hits.len() {
on_progress(AnalysisProgress::PhaseTicked);
}
on_progress(AnalysisProgress::PhaseFinished);
Vec::new()
}
SourceLineageGraph::Compiled { idg, .. } => enumerate_source_candidates(
&SourceLineageEnumerationContext {
ws,
global: global.as_ref(),
idg,
graph_config: &source_graph_config,
caches: source_graph_caches,
lineage_limits: options.lineage_limits,
},
&source_groups,
source_hits.len(),
&mut on_progress,
),
};
if !options.exclude_files.is_empty() || options.exclude_tests {
candidates.retain(|candidate| {
!source_candidate_has_excluded_path(
ws,
global.as_ref(),
candidate,
&options.exclude_files,
options.exclude_tests,
&pack.metadata.test_path_patterns,
)
});
}
let candidates = combine_source_analysis_candidates(candidates);
let lineage_summary = SourceLineageSummary::from_candidates(&candidates);
let runtime_disabled_rules = crate::matcher::drain_runtime_disabled_rules();
let mut analysis_incomplete_reasons: BTreeSet<String> =
workspace_analysis_incomplete_reasons(ws, &scan_files, source_scope.resolution())
.into_iter()
.collect();
if unattributed_source_matches > 0 {
analysis_incomplete_reasons.insert(format!(
"unattributed-source-matches:{unattributed_source_matches}"
));
}
if !runtime_disabled_rules.is_empty() {
analysis_incomplete_reasons
.insert(format!("runtime-disabled-rules:{}", runtime_disabled_rules.len()));
}
if lineage_summary.incomplete_flows > 0 {
analysis_incomplete_reasons.insert(format!(
"incomplete-source-lineage-flows:{}",
lineage_summary.incomplete_flows
));
}
if lineage_summary.truncated_hop_flows > 0 {
analysis_incomplete_reasons.insert(format!(
"source-lineage-truncated-hop-flows:{}",
lineage_summary.truncated_hop_flows
));
}
if lineage_summary.omitted_paths > 0 {
analysis_incomplete_reasons.insert(format!(
"source-lineage-omitted-paths:{}",
lineage_summary.omitted_paths
));
}
let analysis_incomplete_reasons: Vec<String> = analysis_incomplete_reasons.into_iter().collect();
finish_taint_cache_write_through(ws, source_scope.cache_persist_started, &mut on_progress);
Ok(SourceAnalysisReport {
candidates,
source_rule_count: sources.len(),
lineage_summary,
analysis_complete: analysis_incomplete_reasons.is_empty(),
analysis_incomplete_reasons,
runtime_disabled_rules,
})
}
pub fn select_rules<'a, F>(
pack: &'a Rulepack,
want_kind: RuleKind,
exact: Option<&str>,
regex: Option<&str>,
extra: F,
) -> Result<Vec<&'a Rule>>
where
F: Fn(&Rule) -> bool,
{
let compiled_regex = regex
.map(|pattern| {
Regex::new(pattern).map_err(|err| anyhow::anyhow!("invalid rule regex `{pattern}`: {err}"))
})
.transpose()?;
Ok(pack
.all_rules()
.into_iter()
.filter(|rule| rule.kind == want_kind && rule.enabled)
.filter(|rule| exact.is_none_or(|id| rule.id == id || rule.aliases.iter().any(|alias| alias == id)))
.filter(|rule| {
compiled_regex.as_ref().is_none_or(|regex| {
regex.is_match(&rule.id) || rule.aliases.iter().any(|alias| regex.is_match(alias))
})
})
.filter(|rule| extra(rule))
.collect())
}
pub fn filter_rules_to_workspace_languages<'a>(ws: &Workspace, rules: &mut Vec<&'a Rule>) {
let languages = workspace_languages(ws);
if languages.is_empty() {
rules.clear();
return;
}
rules.retain(|rule| languages.contains(rule.language.as_str()));
}
pub fn workspace_languages(ws: &Workspace) -> AHashSet<String> {
let mut languages = AHashSet::new();
for file in ws.db().vfs().all_files() {
if let Some(adapter) = ws.db().adapter_for(file) {
languages.insert(adapter.language_id().as_str().to_string());
}
}
languages
}
pub fn source_inventory(
ws: &Workspace,
pack: &Rulepack,
options: SecurityInventoryOptions,
) -> Result<Vec<RuleMatch>> {
source_inventory_with_progress(ws, pack, options, |_| {})
}
pub fn source_inventory_with_progress<F>(
ws: &Workspace,
pack: &Rulepack,
options: SecurityInventoryOptions,
mut on_progress: F,
) -> Result<Vec<RuleMatch>>
where
F: FnMut(AnalysisProgress),
{
let _dependency_package_snapshot = begin_dependency_package_snapshot(ws, pack);
let mut selected = select_rules(
pack,
RuleKind::Source,
options.rule.as_deref(),
options.rule_regex.as_deref(),
|rule| {
source_rule_matches_filters(
rule,
options.trust.as_deref(),
options.category.as_deref(),
options.tag.as_deref(),
) && options
.severity
.is_none_or(|min| rule.severity.is_some_and(|severity| severity >= min))
},
)?;
filter_rules_to_workspace_languages(ws, &mut selected);
let scan_files = security_scan_files(
ws,
&options.files,
&options.exclude_files,
false,
&pack.metadata.test_path_patterns,
);
let total_files = scan_files.len() as u64;
let rulepack_typing = crate::matcher::build_rulepack_typing(&pack.all_rules());
let mut matches = gather_inventory_matches_phased(
ws,
&selected,
"matching source rules",
&scan_files,
total_files,
&rulepack_typing,
&mut on_progress,
);
on_progress(AnalysisProgress::PhaseStarted {
label: "finalizing matches",
total: 0,
});
filter_by_path(ws, &mut matches, &options.files, &options.exclude_files);
sort_matches(&mut matches);
dedup_inventory_matches(&mut matches);
on_progress(AnalysisProgress::PhaseFinished);
Ok(matches)
}
pub fn sink_inventory(
ws: &Workspace,
pack: &Rulepack,
options: SecurityInventoryOptions,
) -> Result<Vec<RuleMatch>> {
sink_inventory_with_progress(ws, pack, options, |_| {})
}
pub fn sink_inventory_with_progress<F>(
ws: &Workspace,
pack: &Rulepack,
options: SecurityInventoryOptions,
mut on_progress: F,
) -> Result<Vec<RuleMatch>>
where
F: FnMut(AnalysisProgress),
{
let _dependency_package_snapshot = begin_dependency_package_snapshot(ws, pack);
let mut selected = select_rules(
pack,
RuleKind::Sink,
options.rule.as_deref(),
options.rule_regex.as_deref(),
|rule| {
options
.severity
.is_none_or(|min| rule.severity.is_some_and(|severity| severity >= min))
&& options
.tag
.as_deref()
.is_none_or(|tag| rule.tag.as_deref() == Some(tag))
&& options
.category
.as_deref()
.is_none_or(|category| rule_matches_category(pack, rule, category))
},
)?;
filter_rules_to_workspace_languages(ws, &mut selected);
let scan_files = security_scan_files(
ws,
&options.files,
&options.exclude_files,
false,
&pack.metadata.test_path_patterns,
);
let rulepack_typing = crate::matcher::build_rulepack_typing(&pack.all_rules());
on_progress(AnalysisProgress::PhaseStarted {
label: "matching sink rules",
total: scan_files.len() as u64,
});
let mut matches = match_rules_against_facts_for_sink_inventory_with_progress_on_files(
ws,
&selected,
&scan_files,
&rulepack_typing,
|| on_progress(AnalysisProgress::PhaseTicked),
);
on_progress(AnalysisProgress::PhaseFinished);
on_progress(AnalysisProgress::PhaseStarted {
label: "finalizing matches",
total: 0,
});
filter_by_path(ws, &mut matches, &options.files, &options.exclude_files);
sort_matches(&mut matches);
dedup_inventory_matches(&mut matches);
on_progress(AnalysisProgress::PhaseFinished);
Ok(matches)
}
pub fn sanitizer_inventory(
ws: &Workspace,
pack: &Rulepack,
options: SecurityInventoryOptions,
) -> Result<Vec<RuleMatch>> {
sanitizer_inventory_with_progress(ws, pack, options, |_| {})
}
pub fn sanitizer_inventory_with_progress<F>(
ws: &Workspace,
pack: &Rulepack,
options: SecurityInventoryOptions,
mut on_progress: F,
) -> Result<Vec<RuleMatch>>
where
F: FnMut(AnalysisProgress),
{
let _dependency_package_snapshot = begin_dependency_package_snapshot(ws, pack);
let mut selected = select_rules(
pack,
RuleKind::Sanitizer,
options.rule.as_deref(),
options.rule_regex.as_deref(),
|rule| {
rule.tag.as_deref().is_none_or(|tag| {
!sanitizer_tag_is_recognized_non_crediting(&pack.metadata, tag)
}) && options
.tag
.as_deref()
.is_none_or(|tag| rule.tag.as_deref() == Some(tag))
&& options
.severity
.is_none_or(|min| rule.severity.is_some_and(|severity| severity >= min))
&& options
.category
.as_deref()
.is_none_or(|category| rule_matches_category(pack, rule, category))
},
)?;
filter_rules_to_workspace_languages(ws, &mut selected);
let scan_files = security_scan_files(
ws,
&options.files,
&options.exclude_files,
false,
&pack.metadata.test_path_patterns,
);
let total_files = scan_files.len() as u64;
let rulepack_typing = crate::matcher::build_rulepack_typing(&pack.all_rules());
let mut matches = gather_inventory_matches_phased(
ws,
&selected,
"matching sanitizer rules",
&scan_files,
total_files,
&rulepack_typing,
&mut on_progress,
);
on_progress(AnalysisProgress::PhaseStarted {
label: "finalizing matches",
total: 0,
});
filter_by_path(ws, &mut matches, &options.files, &options.exclude_files);
sort_matches(&mut matches);
dedup_inventory_matches(&mut matches);
on_progress(AnalysisProgress::PhaseFinished);
Ok(matches)
}
pub fn dependency_inventory(
ws: &Workspace,
pack: &Rulepack,
root: &Path,
options: DependencyInventoryOptions,
) -> DependencyInventory {
let mut inv = build_inventory(pack, ws, root);
if let Some(framework) = options.framework.as_deref() {
inv.rows.retain(|row| {
row.key == framework || row.signals.iter().any(|signal| signal.contains(framework))
});
}
if let Some(severity) = options.severity {
inv.rows
.retain(|row| row.severity.is_some_and(|row_severity| row_severity >= severity));
}
let filter_root = ws.db().workspace_root();
if !options.files.is_empty() {
inv.rows.retain(|row| {
row.evidence_files.iter().any(|evidence| {
options
.files
.iter()
.any(|file| path_filter_matches_with_root(filter_root.as_deref(), evidence, file))
})
});
}
if !options.exclude_files.is_empty() {
inv.rows.retain(|row| {
!row.evidence_files.iter().any(|evidence| {
options
.exclude_files
.iter()
.any(|file| path_filter_matches_with_root(filter_root.as_deref(), evidence, file))
})
});
}
inv.rows
.sort_by(|a, b| (a.language.as_str(), a.key.as_str()).cmp(&(b.language.as_str(), b.key.as_str())));
inv
}
pub fn security_match_rows(pack: &Rulepack, matches: &[RuleMatch]) -> Vec<SecurityMatchRow> {
matches
.iter()
.map(|rule_match| {
let rule = pack.find_rule_by_id(&rule_match.rule_id);
SecurityMatchRow {
rule_id: rule_match.rule_id.clone(),
tag: rule.and_then(|rule| rule.tag.clone()),
severity: rule.and_then(|rule| rule.severity.map(|severity| severity.as_str().to_string())),
category: rule.and_then(|rule| rule.category.clone()),
trust: rule
.and_then(|rule| rule.trust)
.map(|trust| trust.as_str().to_string()),
cwe: rule.map(|rule| rule.cwe.clone()).unwrap_or_default(),
owasp: rule.map(|rule| rule.owasp.clone()).unwrap_or_default(),
frameworks: rule.map(|rule| rule.frameworks.clone()).unwrap_or_default(),
packages: rule.map(|rule| rule.packages.clone()).unwrap_or_default(),
language: rule_match.language.clone(),
file: rule_match.file.clone(),
line: rule_match.line,
column: rule_match.column,
text: rule_match.match_text.clone(),
enclosing_fn: rule_match.enclosing_fn.clone(),
description: rule.map(|rule| rule.description.clone()),
}
})
.collect()
}
pub fn pack_inventory(pack: &Rulepack, options: PackInventoryOptions) -> Vec<PackRuleRow> {
select_pack_rules(pack, &options)
.into_iter()
.map(|rule| PackRuleRow {
rule_id: rule.id.clone(),
language: rule.language.clone(),
kind: rule_kind_str(rule.kind).to_string(),
family: pack.normalized_sink_family(rule_family(&rule.id)).to_string(),
tag: rule.tag.clone(),
severity: rule.severity.map(|severity| severity.as_str().to_string()),
enabled: rule.enabled,
packages: rule.packages.clone(),
frameworks: rule.frameworks.clone(),
description: rule.description.clone(),
})
.collect()
}
pub fn pack_audit(pack: &Rulepack, lang_filter: Option<&str>) -> PackAuditReport {
type Counts = AHashMap<(String, String), (u32, u32)>;
let mut sink_counts: Counts = AHashMap::new();
let mut source_counts: AHashMap<String, (u32, u32)> = AHashMap::new();
let mut sanitizer_counts: AHashMap<String, (u32, u32)> = AHashMap::new();
let mut langs: AHashSet<String> = AHashSet::new();
for rule in pack.all_rules() {
if lang_filter.is_some_and(|lang| rule.language != lang) {
continue;
}
langs.insert(rule.language.clone());
match rule.kind {
RuleKind::Sink => {
let mut families: BTreeSet<String> = BTreeSet::new();
families.insert(pack.normalized_sink_family(rule_family(&rule.id)).to_string());
for alias in &rule.aliases {
families.insert(pack.normalized_sink_family(rule_family(alias)).to_string());
}
for family in families {
let entry = sink_counts
.entry((rule.language.clone(), family))
.or_insert((0, 0));
if rule.enabled {
entry.0 += 1;
} else {
entry.1 += 1;
}
}
}
RuleKind::Source => {
let entry = source_counts.entry(rule.language.clone()).or_insert((0, 0));
if rule.enabled {
entry.0 += 1;
} else {
entry.1 += 1;
}
}
RuleKind::Sanitizer => {
let entry = sanitizer_counts.entry(rule.language.clone()).or_insert((0, 0));
if rule.enabled {
entry.0 += 1;
} else {
entry.1 += 1;
}
}
RuleKind::Typing => {}
}
}
let mut languages: Vec<String> = langs.into_iter().collect();
languages.sort();
let languages = languages
.into_iter()
.map(|language| {
let metadata = pack.metadata.languages.get(&language);
let sinks = pack
.metadata
.canonical_sink_families
.iter()
.map(|family| {
let (enabled, disabled) = sink_counts
.get(&(language.clone(), family.clone()))
.copied()
.unwrap_or((0, 0));
(
family.clone(),
PackAuditFamilyCount {
enabled,
disabled,
not_applicable: metadata.is_some_and(|metadata| {
metadata
.not_applicable_sink_families
.iter()
.any(|not_applicable| not_applicable == family)
}),
},
)
})
.collect();
let (source_enabled, source_disabled) = source_counts.get(&language).copied().unwrap_or((0, 0));
let (sanitizer_enabled, sanitizer_disabled) =
sanitizer_counts.get(&language).copied().unwrap_or((0, 0));
PackAuditLanguage {
language,
sinks,
sources: PackAuditCount {
enabled: source_enabled,
disabled: source_disabled,
},
sanitizers: PackAuditCount {
enabled: sanitizer_enabled,
disabled: sanitizer_disabled,
},
}
})
.collect();
PackAuditReport {
canonical_sink_families: pack.metadata.canonical_sink_families.clone(),
sink_family_short_labels: pack.metadata.sink_family_short_labels.clone(),
languages,
}
}
pub fn pack_tree(pack: &Rulepack, options: PackInventoryOptions) -> PackTreeReport {
let rules = select_pack_rules(pack, &options);
pack_tree_for_rules(pack, &rules)
}
pub fn pack_tree_for_rules(pack: &Rulepack, rules: &[&Rule]) -> PackTreeReport {
let mut grouped: AHashMap<String, AHashMap<&'static str, AHashMap<String, Vec<&Rule>>>> = AHashMap::new();
for rule in rules {
grouped
.entry(rule.language.clone())
.or_default()
.entry(rule_kind_str(rule.kind))
.or_default()
.entry(tree_file_rel(pack, rule))
.or_default()
.push(*rule);
}
let mut languages: Vec<String> = grouped.keys().cloned().collect();
languages.sort();
let languages = languages
.into_iter()
.map(|language| {
let mut kinds = BTreeMap::new();
if let Some(grouped_kinds) = grouped.get(&language) {
for kind in ["source", "sink", "sanitizer"] {
let Some(files) = grouped_kinds.get(kind) else {
continue;
};
let mut file_names: Vec<String> = files.keys().cloned().collect();
file_names.sort();
let file_rows = file_names
.into_iter()
.map(|file_name| {
let mut rules = files[&file_name].clone();
rules.sort_by(|a, b| a.id.cmp(&b.id));
PackTreeFile {
file: tree_file_path(pack, &language, kind, &file_name),
rules: rules
.into_iter()
.map(|rule| PackTreeRule {
id: rule.id.clone(),
severity: rule.severity.map(|s| s.as_str().to_string()),
enabled: rule.enabled,
tag: rule.tag.clone(),
})
.collect(),
}
})
.collect();
kinds.insert(kind.to_string(), file_rows);
}
}
PackTreeLanguage { language, kinds }
})
.collect();
PackTreeReport { languages }
}
fn gather_matches_phased<F>(
ws: &Workspace,
rules: &[&Rule],
label: &'static str,
scan_files: &[FileId],
total_files: u64,
factory: &Arc<crate::matcher::RulepackTyping>,
on_progress: &mut F,
) -> Vec<RuleMatch>
where
F: FnMut(AnalysisProgress),
{
on_progress(AnalysisProgress::PhaseStarted {
label,
total: total_files,
});
let matches = match_rules_against_facts_with_progress_on_files(ws, rules, scan_files, factory, || {
on_progress(AnalysisProgress::PhaseTicked);
});
on_progress(AnalysisProgress::PhaseFinished);
matches
}
fn gather_taint_support_matches_phased<F>(
ws: &Workspace,
rules: &[&Rule],
label: &'static str,
scan_files: &[FileId],
total_files: u64,
factory: &Arc<crate::matcher::RulepackTyping>,
on_progress: &mut F,
) -> Vec<RuleMatch>
where
F: FnMut(AnalysisProgress),
{
on_progress(AnalysisProgress::PhaseStarted {
label,
total: total_files,
});
let matches = match_rules_against_facts_for_taint_support_with_progress_on_files(
ws,
rules,
scan_files,
factory,
|| {
on_progress(AnalysisProgress::PhaseTicked);
},
);
on_progress(AnalysisProgress::PhaseFinished);
matches
}
fn gather_inventory_matches_phased<F>(
ws: &Workspace,
rules: &[&Rule],
label: &'static str,
scan_files: &[FileId],
total_files: u64,
factory: &Arc<crate::matcher::RulepackTyping>,
on_progress: &mut F,
) -> Vec<RuleMatch>
where
F: FnMut(AnalysisProgress),
{
on_progress(AnalysisProgress::PhaseStarted {
label,
total: total_files,
});
let matches = match_rules_against_facts_for_inventory_with_progress_on_files(
ws,
rules,
scan_files,
factory,
|| {
on_progress(AnalysisProgress::PhaseTicked);
},
);
on_progress(AnalysisProgress::PhaseFinished);
matches
}
pub fn source_rule_matches_filters(
rule: &Rule,
trust: Option<&str>,
category: Option<&str>,
tag: Option<&str>,
) -> bool {
trust.is_none_or(|t| rule.trust.is_some_and(|rt| rt.as_str() == t))
&& category.is_none_or(|c| rule.category.as_deref() == Some(c))
&& tag.is_none_or(|t| rule.tag.as_deref() == Some(t))
}
fn filter_source_hits_by_metadata(
hits: &mut Vec<RuleMatch>,
pack: &Rulepack,
trust: Option<&str>,
category: Option<&str>,
tag: Option<&str>,
) {
if trust.is_none() && category.is_none() && tag.is_none() {
return;
}
hits.retain(|hit| source_hit_matches_metadata(hit, pack, trust, category, tag));
}
fn concrete_source_param_bases(
pack: &Rulepack,
hits: &[RuleMatch],
) -> AHashMap<(String, String), AHashSet<String>> {
let mut out: AHashMap<(String, String), AHashSet<String>> = AHashMap::default();
for hit in hits {
if hit.origin != MatchOrigin::Rulepack {
continue;
}
if pack.find_rule_by_id(&hit.rule_id).is_some_and(|rule| {
rule.constraints
.iter()
.any(|constraint| matches!(constraint, ConstraintKind::SinkTagIn { .. }))
}) {
continue;
}
let Some(fn_name) = hit.enclosing_fn.clone() else {
continue;
};
if let Some(base) = source_expr_base_identifier(&hit.match_text) {
out.entry((hit.file.clone(), fn_name))
.or_default()
.insert(base.to_string());
}
}
out
}
fn inferred_param_subsumed_by_concrete(
inferred: &RuleMatch,
concrete: &AHashMap<(String, String), AHashSet<String>>,
) -> bool {
if !matches!(
inferred.origin,
MatchOrigin::InferredUnreferencedParameter | MatchOrigin::InferredFrameworkParameter
) {
return false;
}
let Some(fn_name) = inferred.enclosing_fn.as_ref() else {
return false;
};
let param = inferred.match_text.trim();
!param.is_empty()
&& concrete
.get(&(inferred.file.clone(), fn_name.clone()))
.is_some_and(|bases| bases.contains(param))
}
fn source_expr_base_identifier(text: &str) -> Option<&str> {
let trimmed = text.trim();
let end = trimmed
.find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$' || c == '@'))
.unwrap_or(trimmed.len());
let base = &trimmed[..end];
(!base.is_empty()).then_some(base)
}
fn source_hit_matches_metadata(
hit: &RuleMatch,
pack: &Rulepack,
trust: Option<&str>,
category: Option<&str>,
tag: Option<&str>,
) -> bool {
if hit.origin != MatchOrigin::Rulepack {
return trust.is_none_or(|t| t == "local")
&& category.is_none_or(|c| c == "inferred")
&& tag.is_none_or(|t| t == "entry-point");
}
pack.find_rule_by_id(&hit.rule_id)
.is_some_and(|rule| source_rule_matches_filters(rule, trust, category, tag))
}
fn source_finding_match(hit: &RuleMatch, pack: &Rulepack) -> Option<FindingMatch> {
if hit.origin != MatchOrigin::Rulepack {
Some(FindingMatch::from_inferred(hit))
} else {
pack.find_rule_by_id(&hit.rule_id)
.map(|rule| FindingMatch::from_rule_match(hit, rule))
}
}
fn func_id_for_match(ws: &Workspace, hit: &RuleMatch) -> Option<FuncId> {
let expected_name = hit.enclosing_fn.as_deref();
let global = ws.compiler_header_index();
if let Some(entry) = ws
.enclosing_index()
.enclosing_for(global.as_ref(), hit.span.file, hit.span.start)
{
if expected_name.is_none_or(|name| name == entry.name) {
return Some(FuncId::new(entry.symbol.raw()));
}
}
let name = expected_name?;
let decls = global.decls_in(hit.span.file);
let mut best_containing: Option<(u64, FuncId)> = None;
let mut unique_named: Option<FuncId> = None;
let mut named_count = 0usize;
for decl in decls {
if decl.name != name {
continue;
}
named_count = named_count.saturating_add(1);
let fid = FuncId::new(decl.symbol.raw());
unique_named = Some(fid);
let body_span = decl.body_span.unwrap_or(decl.span);
if span_contains(body_span, hit.span) || span_contains(decl.span, hit.span) {
let width = decl.span.end.saturating_sub(decl.span.start);
if best_containing.is_none_or(|(best_width, _)| width < best_width) {
best_containing = Some((width, fid));
}
}
}
let resolved = best_containing
.map(|(_, fid)| fid)
.or_else(|| (named_count == 1).then_some(unique_named).flatten());
if resolved.is_none() {
bonsai_diagnostics::debug_log!(
"security-phase",
"unattributed match rule={} origin={:?} file={} span={}..{} expected={} named_candidates={named_count}",
hit.rule_id,
hit.origin,
hit.span.file.raw(),
hit.span.start,
hit.span.end,
expected_name.unwrap_or("<none>"),
);
}
resolved
}
struct SourceLineageEmission<'a> {
records: Vec<&'a TaintedCallEdge>,
truncated_hops: bool,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct SourceLineageEnumeration {
emitted_paths: usize,
omitted_paths: usize,
truncated_paths: usize,
max_hops: usize,
max_paths: usize,
}
fn collect_tainted_source_lineages<'a>(
records: &'a [TaintedCallEdge],
source: FuncId,
max_extra: usize,
max_paths: usize,
) -> (Vec<SourceLineageEmission<'a>>, SourceLineageEnumeration) {
let mut stats = SourceLineageEnumeration {
max_hops: max_extra,
max_paths,
..Default::default()
};
if max_extra == 0 || max_paths == 0 || !records.iter().any(|record| record.trace_id != 0) {
return (Vec::new(), stats);
}
let child_trace_ids: AHashSet<u64> = records
.iter()
.filter_map(|record| record.parent_trace_id)
.collect();
let mut by_id: AHashMap<u64, &TaintedCallEdge> = AHashMap::new();
for record in records {
if record.trace_id != 0 {
by_id.entry(record.trace_id).or_insert(record);
}
}
let mut endpoints: Vec<&TaintedCallEdge> = records
.iter()
.filter(|record| record.trace_id != 0)
.filter(|record| !child_trace_ids.contains(&record.trace_id))
.collect();
endpoints.sort_by_key(|record| {
(
record.call_span.file.raw(),
record.call_span.start,
record.call_span.end,
record.trace_id,
)
});
let mut out = Vec::new();
let mut seen: AHashSet<Vec<u64>> = AHashSet::new();
for endpoint in endpoints {
let Some(mut lineage) = lineage_records_for_trace_id_indexed(&by_id, endpoint.trace_id) else {
continue;
};
if lineage.first().is_none_or(|record| record.caller != source) {
continue;
}
let truncated_hops = lineage.len() > max_extra;
if lineage.len() > max_extra {
lineage.truncate(max_extra);
}
let key: Vec<u64> = lineage.iter().map(|record| record.trace_id).collect();
if !key.is_empty() && seen.insert(key) {
if out.len() < max_paths {
if truncated_hops {
stats.truncated_paths += 1;
}
out.push(SourceLineageEmission {
records: lineage,
truncated_hops,
});
stats.emitted_paths += 1;
} else {
stats.omitted_paths += 1;
}
}
}
(out, stats)
}
fn chain_precision_for_records(records: &[&TaintedCallEdge]) -> Precision {
records.iter().fold(Precision::Exact, |precision, record| {
precision.meet(record.precision)
})
}
#[derive(Clone, Debug)]
struct UnresolvedWorkspaceCallSite {
span: Span,
name: String,
}
struct GraphUnresolvedCallIndex {
by_caller: AHashMap<FuncId, Vec<UnresolvedWorkspaceCallSite>>,
}
impl GraphUnresolvedCallIndex {
fn new(call_graph: &bonsai_callgraph::ResolvedCallGraph, graph: &EntryTaintGraph) -> Self {
let unresolved_sites: AHashSet<(FuncId, Span)> =
call_graph.unresolved_workspace_call_sites().collect();
let mut seen_sites: AHashSet<(FuncId, Span)> = AHashSet::new();
let mut by_caller: AHashMap<FuncId, Vec<UnresolvedWorkspaceCallSite>> = AHashMap::new();
for call in &graph.tainted_calls {
if !matches!(call.kind, bonsai_taint::TaintedCallKind::Call)
|| !unresolved_sites.contains(&(call.caller, call.call_span))
|| !seen_sites.insert((call.caller, call.call_span))
{
continue;
}
by_caller
.entry(call.caller)
.or_default()
.push(UnresolvedWorkspaceCallSite {
span: call.call_span,
name: call.name.clone(),
});
}
for sites in by_caller.values_mut() {
sites.sort_by(|a, b| {
(a.span.start, a.span.end, a.name.as_str()).cmp(&(b.span.start, b.span.end, b.name.as_str()))
});
sites.dedup_by(|a, b| a.span == b.span && a.name == b.name);
}
Self { by_caller }
}
fn reasons_for_terminal_call(&self, terminal_call: &TaintedCall) -> Vec<String> {
let Some(sites) = self.by_caller.get(&terminal_call.caller) else {
return Vec::new();
};
let mut reasons = Vec::new();
for site in sites {
if site.span == terminal_call.call_span && site.name == terminal_call.name {
continue;
}
if unresolved_call_site_is_in_terminal_expression(terminal_call.call_span, site.span) {
reasons.push(format!("unresolved-call:{}", site.name));
}
}
reasons.sort();
reasons.dedup();
reasons
}
}
fn unresolved_call_site_is_in_terminal_expression(terminal_span: Span, unresolved_span: Span) -> bool {
span_contains(terminal_span, unresolved_span)
}
struct CallEvidence {
chain_funcs: Vec<FuncId>,
sanitizer_candidate_funcs: Vec<FuncId>,
chain_names: Vec<String>,
chain_precision: Precision,
taint_path: Vec<TaintPropagationStep>,
sink_tainted_args: Vec<TaintedArgInfo>,
}
fn build_call_evidence<'a>(
ws: &Workspace,
global: &bonsai_index::GlobalIndex,
trace_index: &AHashMap<u64, &'a TaintedCallEdge>,
canonical_chain_index: &CanonicalChainIndex<'a>,
source_func: FuncId,
call: &TaintedCall,
) -> Option<CallEvidence> {
let original_records = lineage_records_for_call_indexed(trace_index, call).unwrap_or_default();
let (chain_funcs, sanitizer_candidate_funcs, chain_precision, taint_path) =
if let Some(primary) = chain_funcs_for_lineage(&original_records, source_func, call.caller) {
let mut records = original_records;
let sanitizer_candidate_funcs =
sanitizer_candidate_funcs_for_lineage(&records, source_func, call.caller);
let mut chain_funcs = rewrite_chain_with_canonical_path(
primary.clone(),
canonical_chain_index,
source_func,
call.caller,
);
if chain_funcs != primary {
match canonical_chain_index.records_along_chain(&chain_funcs) {
Some(rewritten) => records = rewritten,
None => chain_funcs = primary,
}
}
let chain_precision = chain_precision_for_records(&records);
let taint_path = taint_path_for_lineage(ws, global, &records, Some(call));
(
chain_funcs,
sanitizer_candidate_funcs,
chain_precision,
taint_path,
)
} else {
return None;
};
if !chain_precision.is_semantic() {
return None;
}
let chain_names = chain_names_for_path(ws, global, &chain_funcs)?;
let sink_decl = ws.exact_decl(SymbolId::new(call.caller.raw()));
let sink_events = sink_decl.as_deref().map(|decl| decl.flow_events.as_slice());
let mut sink_tainted_args: Vec<TaintedArgInfo> = call
.tainted_args
.iter()
.map(|arg| {
sink_events.map_or_else(
|| TaintedArgInfo {
index: arg.index,
value_text: arg.value_text.clone(),
..TaintedArgInfo::default()
},
|events| tainted_arg_info_from_events(events, call.call_span, arg),
)
})
.collect();
if let Some(receiver) = call.tainted_receiver.as_deref() {
sink_tainted_args.push(TaintedArgInfo {
index: usize::MAX,
value_text: receiver.to_string(),
place: Some(receiver.to_string()),
source_names: Vec::new(),
});
}
Some(CallEvidence {
chain_funcs,
sanitizer_candidate_funcs,
chain_names,
chain_precision,
taint_path,
sink_tainted_args,
})
}
fn sanitizer_candidate_funcs_for_lineage(
records: &[&TaintedCallEdge],
source_func: FuncId,
terminal_func: FuncId,
) -> Vec<FuncId> {
let mut funcs = Vec::with_capacity(records.len().saturating_mul(2).saturating_add(2));
push_unique_func(&mut funcs, source_func);
for record in records {
push_unique_func(&mut funcs, record.caller);
push_unique_func(&mut funcs, record.callee);
}
push_unique_func(&mut funcs, terminal_func);
funcs
}
fn push_unique_func(funcs: &mut Vec<FuncId>, func: FuncId) {
if !funcs.contains(&func) {
funcs.push(func);
}
}
#[cfg(test)]
fn lineage_records_for_call<'a>(
records: &'a [TaintedCallEdge],
terminal_call: &TaintedCall,
) -> Option<Vec<&'a TaintedCallEdge>> {
let by_id = trace_record_index(records);
lineage_records_for_call_indexed(&by_id, terminal_call)
}
fn trace_record_index(records: &[TaintedCallEdge]) -> AHashMap<u64, &TaintedCallEdge> {
let mut by_id: AHashMap<u64, &TaintedCallEdge> = AHashMap::new();
for record in records {
if record.trace_id != 0 {
by_id.entry(record.trace_id).or_insert(record);
}
}
by_id
}
fn lineage_records_for_call_indexed<'a>(
by_id: &AHashMap<u64, &'a TaintedCallEdge>,
terminal_call: &TaintedCall,
) -> Option<Vec<&'a TaintedCallEdge>> {
match terminal_call.parent_trace_id {
Some(trace_id) => lineage_records_for_trace_id_indexed(by_id, trace_id),
None => Some(Vec::new()),
}
}
fn lineage_records_for_trace_id_indexed<'a>(
by_id: &AHashMap<u64, &'a TaintedCallEdge>,
trace_id: u64,
) -> Option<Vec<&'a TaintedCallEdge>> {
let mut current = Some(trace_id);
let mut lineage = Vec::new();
let mut seen = AHashSet::new();
while let Some(trace_id) = current {
if !seen.insert(trace_id) {
return None;
}
let record = *by_id.get(&trace_id)?;
lineage.push(record);
current = record.parent_trace_id;
}
lineage.reverse();
Some(lineage)
}
struct CanonicalChainIndex<'a> {
adjacency: AHashMap<FuncId, Vec<(FuncId, bool)>>,
edge_has_any: AHashSet<(FuncId, FuncId)>,
edge_has_real: AHashSet<(FuncId, FuncId)>,
edge_record: AHashMap<(FuncId, FuncId), &'a TaintedCallEdge>,
best_chain_trees: std::cell::RefCell<AHashMap<FuncId, CanonicalBestTree>>,
}
struct CanonicalBestTree {
best: AHashMap<FuncId, (u32, u32)>,
predecessor: AHashMap<FuncId, FuncId>,
}
impl<'a> CanonicalChainIndex<'a> {
fn new(records: &'a [TaintedCallEdge], call_graph: &'a bonsai_callgraph::ResolvedCallGraph) -> Self {
let mut edge_synthetic: AHashMap<(FuncId, FuncId), bool> = AHashMap::default();
let mut edge_has_any = AHashSet::default();
let mut edge_has_real = AHashSet::default();
let mut edge_record: AHashMap<(FuncId, FuncId), &'a TaintedCallEdge> = AHashMap::default();
let mut callgraph_edge_cache: AHashMap<(FuncId, FuncId), bool> = AHashMap::default();
for record in records {
let edge = (record.caller, record.callee);
let has_semantic_call_edge = *callgraph_edge_cache
.entry(edge)
.or_insert_with(|| semantic_callgraph_has_edge(call_graph, record.caller, record.callee));
let is_synthetic = edge_is_synthetic(record, has_semantic_call_edge);
edge_has_any.insert(edge);
if !is_synthetic {
if !edge_has_real.contains(&edge) {
edge_record.insert(edge, record);
}
edge_has_real.insert(edge);
} else {
edge_record.entry(edge).or_insert(record);
}
edge_synthetic
.entry(edge)
.and_modify(|existing| *existing &= is_synthetic)
.or_insert(is_synthetic);
}
let mut adjacency: AHashMap<FuncId, Vec<(FuncId, bool)>> = AHashMap::default();
for ((caller, callee), is_synthetic) in edge_synthetic {
adjacency.entry(caller).or_default().push((callee, is_synthetic));
}
for neighbors in adjacency.values_mut() {
neighbors.sort_by_key(|(callee, is_synthetic)| (callee.raw(), *is_synthetic));
}
Self {
adjacency,
edge_has_any,
edge_has_real,
edge_record,
best_chain_trees: std::cell::RefCell::new(AHashMap::default()),
}
}
fn records_along_chain(&self, chain: &[FuncId]) -> Option<Vec<&'a TaintedCallEdge>> {
if chain.len() < 2 {
return None;
}
chain
.windows(2)
.map(|pair| self.edge_record.get(&(pair[0], pair[1])).copied())
.collect()
}
fn best_chain(&self, source_func: FuncId, terminal_func: FuncId) -> Option<Vec<FuncId>> {
if !self.best_chain_trees.borrow().contains_key(&source_func) {
let tree = canonical_best_tree(self, source_func);
self.best_chain_trees.borrow_mut().insert(source_func, tree);
}
let trees = self.best_chain_trees.borrow();
canonical_chain_from_tree(trees.get(&source_func)?, source_func, terminal_func)
}
}
fn semantic_callgraph_has_edge(
call_graph: &bonsai_callgraph::ResolvedCallGraph,
caller: FuncId,
callee: FuncId,
) -> bool {
call_graph
.callees_of(caller)
.any(|edge| edge.to == callee && edge.precision.is_semantic())
}
fn rewrite_chain_with_canonical_path(
primary: Vec<FuncId>,
index: &CanonicalChainIndex<'_>,
source_func: FuncId,
terminal_func: FuncId,
) -> Vec<FuncId> {
let primary_synth = chain_synth_count(&primary, index);
if primary_synth == 0 {
return primary;
}
let Some(alt) = index.best_chain(source_func, terminal_func) else {
return primary;
};
let alt_synth = chain_synth_count(&alt, index);
if alt_synth < primary_synth {
alt
} else {
primary
}
}
fn edge_is_synthetic(record: &TaintedCallEdge, has_semantic_call_edge: bool) -> bool {
if has_semantic_call_edge {
return false;
}
if record.tainted_args.is_empty() {
return true;
}
record.tainted_args.iter().all(|arg| arg.index == usize::MAX)
}
#[cfg(test)]
mod positional_index_regression_tests {
use super::*;
fn edge_with_arg(index: usize) -> TaintedCallEdge {
TaintedCallEdge {
trace_id: 1,
parent_trace_id: None,
caller: FuncId::new(1),
callee: FuncId::new(2),
call_span: Span::new(FileId::new(0), 10, 20),
tainted_args: vec![bonsai_taint::TaintedArg {
index,
value_text: format!("arg{index}"),
param_name: format!("param{index}"),
place: Some(format!("arg{index}")),
source_names: vec![format!("arg{index}")],
}],
precision: Precision::Exact,
edge_kind: bonsai_callgraph::EdgeKind::Direct,
}
}
#[test]
fn positional_255_is_not_a_synthetic_edge() {
assert!(!edge_is_synthetic(&edge_with_arg(255), false));
assert!(edge_is_synthetic(&edge_with_arg(usize::MAX), false));
}
}
fn chain_synth_count(chain: &[FuncId], index: &CanonicalChainIndex<'_>) -> usize {
let mut count = 0;
for window in chain.windows(2) {
let (a, b) = (window[0], window[1]);
let found_any = index.edge_has_any.contains(&(a, b));
let found_real = index.edge_has_real.contains(&(a, b));
if found_any && !found_real {
count += 1;
}
}
count
}
#[cfg(test)]
fn best_chain_through_real_edges(
index: &CanonicalChainIndex<'_>,
source_func: FuncId,
terminal_func: FuncId,
) -> Option<Vec<FuncId>> {
let tree = canonical_best_tree(index, source_func);
canonical_chain_from_tree(&tree, source_func, terminal_func)
}
fn canonical_best_tree(index: &CanonicalChainIndex<'_>, source_func: FuncId) -> CanonicalBestTree {
use std::collections::BinaryHeap;
let mut heap: BinaryHeap<std::cmp::Reverse<(u32, u32, FuncId)>> = BinaryHeap::new();
let mut best: AHashMap<FuncId, (u32, u32)> = AHashMap::with_capacity(index.adjacency.len());
let mut predecessor: AHashMap<FuncId, FuncId> = AHashMap::with_capacity(index.adjacency.len());
heap.push(std::cmp::Reverse((0, 0, source_func)));
best.insert(source_func, (0, 0));
while let Some(std::cmp::Reverse((synthetic_hops, hops, current))) = heap.pop() {
if best.get(¤t).copied() != Some((synthetic_hops, hops)) {
continue;
}
let Some(neighbors) = index.adjacency.get(¤t) else {
continue;
};
for &(next_f, is_synth) in neighbors {
let next_cost = (
synthetic_hops.saturating_add(u32::from(is_synth)),
hops.saturating_add(1),
);
if best.get(&next_f).is_some_and(|existing| *existing <= next_cost) {
continue;
}
best.insert(next_f, next_cost);
predecessor.insert(next_f, current);
heap.push(std::cmp::Reverse((next_cost.0, next_cost.1, next_f)));
}
}
CanonicalBestTree { best, predecessor }
}
fn canonical_chain_from_tree(
tree: &CanonicalBestTree,
source_func: FuncId,
terminal_func: FuncId,
) -> Option<Vec<FuncId>> {
tree.best.get(&terminal_func)?;
let mut current = terminal_func;
let mut path = vec![current];
while current != source_func {
current = *tree.predecessor.get(¤t)?;
path.push(current);
}
path.reverse();
Some(path)
}
fn chain_funcs_for_lineage(
records: &[&TaintedCallEdge],
source_func: FuncId,
terminal_func: FuncId,
) -> Option<Vec<FuncId>> {
if records.is_empty() {
return (source_func == terminal_func).then_some(vec![source_func]);
}
let mut funcs = Vec::with_capacity(records.len() + 1);
let first = records.first()?;
if first.caller != source_func {
return None;
}
funcs.push(first.caller);
for record in records {
if funcs.last().copied() != Some(record.caller) {
return None;
}
funcs.push(record.callee);
}
if funcs.last().copied() != Some(terminal_func) {
return None;
}
let mut seen: AHashSet<FuncId> = AHashSet::with_capacity(funcs.len());
let mut deduped: Vec<FuncId> = Vec::with_capacity(funcs.len());
for f in funcs.iter().copied() {
if !seen.insert(f) {
continue;
}
deduped.push(f);
}
Some(deduped)
}
fn propagation_step_for_edge(
ws: &Workspace,
global: &bonsai_index::GlobalIndex,
record: &TaintedCallEdge,
names: &AHashMap<FuncId, String>,
) -> Option<TaintPropagationStep> {
if record.caller == record.callee {
return None;
}
let (file, line, column) = resolve_span_location(ws, record.call_span);
let caller = path_display_name(global, names, record.caller);
let callee = path_display_name(global, names, record.callee);
TaintPropagationStep {
caller,
callee,
file,
line,
column,
tainted_args: record
.tainted_args
.iter()
.map(|arg| TaintPropagationArg {
index: arg.index,
value_text: arg.value_text.clone(),
param_name: arg.param_name.clone(),
})
.collect(),
}
.into()
}
fn propagation_step_for_terminal_call(
ws: &Workspace,
global: &bonsai_index::GlobalIndex,
call: &TaintedCall,
names: &AHashMap<FuncId, String>,
) -> TaintPropagationStep {
let (file, line, column) = resolve_span_location(ws, call.call_span);
let mut tainted_args: Vec<TaintPropagationArg> = call
.tainted_args
.iter()
.map(|arg| TaintPropagationArg {
index: arg.index,
value_text: arg.value_text.clone(),
param_name: String::new(),
})
.collect();
if let Some(receiver) = call.tainted_receiver.as_deref() {
tainted_args.push(TaintPropagationArg {
index: usize::MAX,
value_text: receiver.to_string(),
param_name: receiver.to_string(),
});
}
let caller = path_display_name(global, names, call.caller);
TaintPropagationStep {
caller: if caller == call.name {
func_display_name_with_site(ws, global, call.caller)
} else {
caller
},
callee: call.name.clone(),
file,
line,
column,
tainted_args,
}
}
fn taint_path_for_lineage(
ws: &Workspace,
global: &bonsai_index::GlobalIndex,
records: &[&TaintedCallEdge],
terminal_call: Option<&TaintedCall>,
) -> Vec<TaintPropagationStep> {
let names = path_display_names(ws, global, records, terminal_call);
let mut path: Vec<TaintPropagationStep> = records
.iter()
.filter_map(|record| propagation_step_for_edge(ws, global, record, &names))
.collect();
if let Some(call) = terminal_call {
path.push(propagation_step_for_terminal_call(ws, global, call, &names));
}
normalize_taint_path(path)
}
fn path_display_names(
ws: &Workspace,
global: &bonsai_index::GlobalIndex,
records: &[&TaintedCallEdge],
terminal_call: Option<&TaintedCall>,
) -> AHashMap<FuncId, String> {
let mut funcs: Vec<FuncId> = Vec::with_capacity(records.len() * 2 + 1);
for record in records {
funcs.push(record.caller);
funcs.push(record.callee);
}
if let Some(call) = terminal_call {
funcs.push(call.caller);
}
funcs.sort_unstable();
funcs.dedup();
let mut by_name: BTreeMap<String, Vec<FuncId>> = BTreeMap::new();
for func in &funcs {
by_name
.entry(func_display_name(global, *func))
.or_default()
.push(*func);
}
let mut names = AHashMap::with_capacity(funcs.len());
for (name, ids) in by_name {
let ambiguous = ids.len() > 1;
for func in ids {
let display = if ambiguous {
func_display_name_with_site(ws, global, func)
} else {
name.clone()
};
names.insert(func, display);
}
}
names
}
fn path_display_name(
global: &bonsai_index::GlobalIndex,
names: &AHashMap<FuncId, String>,
func: FuncId,
) -> String {
names
.get(&func)
.cloned()
.unwrap_or_else(|| func_display_name(global, func))
}
fn normalize_taint_path(path: Vec<TaintPropagationStep>) -> Vec<TaintPropagationStep> {
let mut normalized: Vec<TaintPropagationStep> = Vec::with_capacity(path.len());
for step in path {
let Some(previous) = normalized.last_mut() else {
normalized.push(step);
continue;
};
if !same_taint_report_site(previous, &step) {
normalized.push(step);
continue;
}
merge_taint_report_step(previous, step);
}
normalized
}
fn align_terminal_taint_step_to_sink(
mut path: Vec<TaintPropagationStep>,
sink: &RuleMatch,
) -> Vec<TaintPropagationStep> {
let Some(step) = path.last_mut() else {
return path;
};
if !terminal_taint_step_should_align_to_sink(step, sink) {
return path;
}
step.file.clone_from(&sink.file);
step.line = sink.line;
step.column = sink.column;
if !sink.match_text.is_empty() {
step.callee.clone_from(&sink.match_text);
}
normalize_taint_path(path)
}
fn terminal_taint_step_should_align_to_sink(step: &TaintPropagationStep, sink: &RuleMatch) -> bool {
if step.file != sink.file || sink.line == 0 {
return false;
}
if step.line == sink.line && (step.column == sink.column || sink.column == 0) {
return false;
}
if !sink.enclosing_fn.as_deref().is_none_or(|enclosing| {
let caller = display_callee_tail(&step.caller);
caller == enclosing || step.caller == enclosing
}) {
return false;
}
step.line == 0
|| step.line < sink.line
|| (step.line == sink.line && (step.column == 0 || step.column < sink.column))
}
fn same_taint_report_site(left: &TaintPropagationStep, right: &TaintPropagationStep) -> bool {
left.file == right.file && left.line == right.line && (left.line != 0 || left.column == right.column)
}
fn merge_taint_report_step(previous: &mut TaintPropagationStep, next: TaintPropagationStep) {
if previous.caller.is_empty() {
previous.caller = next.caller;
}
if !next.callee.is_empty() {
previous.callee = next.callee;
}
if previous.column == 0 {
previous.column = next.column;
}
for arg in next.tainted_args {
if !previous.tainted_args.iter().any(|existing| {
existing.index == arg.index
&& existing.value_text == arg.value_text
&& existing.param_name == arg.param_name
}) {
previous.tainted_args.push(arg);
}
}
}
fn func_display_name(global: &bonsai_index::GlobalIndex, func: FuncId) -> String {
global
.decl_of(SymbolId::new(func.raw()))
.map(|decl| decl.name.clone())
.unwrap_or_else(|| format!("func#{}", func.raw()))
}
fn func_display_name_with_site(ws: &Workspace, global: &bonsai_index::GlobalIndex, func: FuncId) -> String {
let Some(decl) = global.decl_of(SymbolId::new(func.raw())) else {
return format!("func#{}", func.raw());
};
let file_name = ws
.vfs()
.path(decl.span.file)
.ok()
.and_then(|path| path.file_name().map(|name| name.to_string_lossy().into_owned()))
.unwrap_or_default();
let line = ws
.vfs()
.snapshot(decl.span.file)
.ok()
.map(|snapshot| {
let span_map =
bonsai_common::cached_span_map_arc(decl.span.file, snapshot.version, &snapshot.text);
span_map.line_col(decl.name_span.start).line
})
.unwrap_or_default();
if file_name.is_empty() || line == 0 {
format!("{}#{}", decl.name, func.raw())
} else {
format!("{}@{}:{}", decl.name, file_name, line)
}
}
fn resolve_span_location(ws: &Workspace, span: Span) -> (String, u32, u32) {
let file = span.file;
let path = ws
.vfs()
.path(file)
.map(|file_path| file_path.to_string_lossy().into_owned())
.unwrap_or_default();
if let Ok(snapshot) = ws.vfs().snapshot(file) {
let span_map = bonsai_common::cached_span_map_arc(file, snapshot.version, &snapshot.text);
let line_col = span_map.line_col(span.start);
return (path, line_col.line, line_col.column);
}
(path, 0, 0)
}
fn filter_by_path(ws: &Workspace, matches: &mut Vec<RuleMatch>, files: &[String], exclude: &[String]) {
let root = ws.db().workspace_root();
if !files.is_empty() {
matches.retain(|rule_match| {
files
.iter()
.any(|filter| path_filter_matches_with_root(root.as_deref(), &rule_match.file, filter))
});
}
if !exclude.is_empty() {
matches.retain(|rule_match| {
!exclude
.iter()
.any(|filter| path_filter_matches_with_root(root.as_deref(), &rule_match.file, filter))
});
}
}
fn security_scan_files(
ws: &Workspace,
files: &[String],
exclude_files: &[String],
exclude_tests: bool,
test_path_patterns: &[String],
) -> Vec<FileId> {
let root = ws.db().workspace_root();
ws.db()
.vfs()
.all_files()
.into_iter()
.filter(|&file| {
let path = ws
.vfs()
.path(file)
.ok()
.map(|path| path.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
(files.is_empty()
|| files
.iter()
.any(|filter| path_filter_matches_with_root(root.as_deref(), &path, filter)))
&& !path_is_excluded_with_root(
root.as_deref(),
&path,
exclude_files,
exclude_tests,
test_path_patterns,
)
})
.collect()
}
fn path_is_excluded_with_root(
root: Option<&Path>,
path: &str,
exclude_files: &[String],
exclude_tests: bool,
test_path_patterns: &[String],
) -> bool {
(exclude_tests && path_is_test_file_with_root(root, path, test_path_patterns))
|| exclude_files
.iter()
.any(|filter| path_filter_matches_with_root(root, path, filter))
}
fn path_is_test_file_with_root(root: Option<&Path>, path: &str, test_path_patterns: &[String]) -> bool {
let relative = workspace_relative_filter_path(root, path);
crate::finding::path_is_test_file(&relative, test_path_patterns)
}
fn taint_path_has_excluded_file(
ws: &Workspace,
taint_path: &[TaintPropagationStep],
exclude_files: &[String],
exclude_tests: bool,
test_path_patterns: &[String],
) -> bool {
let root = ws.db().workspace_root();
taint_path.iter().any(|step| {
path_is_excluded_with_root(
root.as_deref(),
&step.file,
exclude_files,
exclude_tests,
test_path_patterns,
)
})
}
fn func_file_path(ws: &Workspace, global: &bonsai_index::GlobalIndex, func: FuncId) -> Option<String> {
let decl = global.decl_of(SymbolId::new(func.raw()))?;
ws.vfs()
.path(decl.span.file)
.ok()
.map(|path| path.to_string_lossy().into_owned())
}
fn source_candidate_has_excluded_path(
ws: &Workspace,
global: &bonsai_index::GlobalIndex,
candidate: &SourceAnalysisCandidate,
exclude_files: &[String],
exclude_tests: bool,
test_path_patterns: &[String],
) -> bool {
let root = ws.db().workspace_root();
path_is_excluded_with_root(
root.as_deref(),
&candidate.source.file,
exclude_files,
exclude_tests,
test_path_patterns,
) || taint_path_has_excluded_file(
ws,
&candidate.taint_path,
exclude_files,
exclude_tests,
test_path_patterns,
) || candidate.path.iter().any(|&func| {
func_file_path(ws, global, func).as_deref().is_some_and(|path| {
path_is_excluded_with_root(
root.as_deref(),
path,
exclude_files,
exclude_tests,
test_path_patterns,
)
})
})
}
fn finding_has_excluded_path(
ws: &Workspace,
finding: &Finding,
exclude_files: &[String],
exclude_tests: bool,
test_path_patterns: &[String],
) -> bool {
let root = ws.db().workspace_root();
path_is_excluded_with_root(
root.as_deref(),
&finding.source.file,
exclude_files,
exclude_tests,
test_path_patterns,
) || path_is_excluded_with_root(
root.as_deref(),
&finding.sink.file,
exclude_files,
exclude_tests,
test_path_patterns,
) || taint_path_has_excluded_file(
ws,
&finding.taint_path,
exclude_files,
exclude_tests,
test_path_patterns,
) || finding.sanitizers_seen.iter().any(|sanitizer| {
path_is_excluded_with_root(
root.as_deref(),
&sanitizer.file,
exclude_files,
exclude_tests,
test_path_patterns,
)
}) || finding.taint_transforms_seen.iter().any(|transform| {
path_is_excluded_with_root(
root.as_deref(),
&transform.file,
exclude_files,
exclude_tests,
test_path_patterns,
)
})
}
fn sort_matches(matches: &mut [RuleMatch]) {
matches.sort_by(|a, b| {
(a.language.as_str(), a.file.as_str(), a.line, a.column).cmp(&(
b.language.as_str(),
b.file.as_str(),
b.line,
b.column,
))
});
}
fn combine_source_analysis_candidates(
flows: Vec<SourceAnalysisCandidate>,
) -> Vec<CombinedSourceAnalysisCandidate> {
let mut groups: Vec<CombinedSourceAnalysisCandidate> = Vec::new();
let mut index: AHashMap<String, usize> = AHashMap::new();
for item in flows {
let key = item.flow_id.clone();
if let Some(&idx) = index.get(&key) {
merge_source_lineage_status(&mut groups[idx].lineage, item.lineage);
groups[idx].precision = groups[idx].precision.meet(item.precision);
if !same_source_site(&groups[idx].source, &item.source)
&& !groups[idx]
.additional_sources
.iter()
.any(|source| same_source_site(source, &item.source))
{
groups[idx].additional_sources.push(item.source);
}
continue;
}
let idx = groups.len();
index.insert(key, idx);
groups.push(CombinedSourceAnalysisCandidate {
source: item.source,
chain_names: item.chain_names,
path: item.path,
flow_id: item.flow_id,
taint_path: item.taint_path,
precision: item.precision,
lineage: item.lineage,
additional_sources: Vec::new(),
});
}
groups
}
fn merge_source_lineage_status(current: &mut SourceLineageStatus, incoming: SourceLineageStatus) {
current.complete = current.complete && incoming.complete;
current.truncated_hops = current.truncated_hops || incoming.truncated_hops;
current.omitted_paths = current.omitted_paths.saturating_add(incoming.omitted_paths);
current.emitted_paths = current.emitted_paths.saturating_add(incoming.emitted_paths);
current.max_hops = current.max_hops.max(incoming.max_hops);
current.max_paths = current.max_paths.max(incoming.max_paths);
}
fn chain_names_for_path(
ws: &Workspace,
global: &bonsai_index::GlobalIndex,
path: &[FuncId],
) -> Option<Vec<String>> {
let named_funcs: Option<Vec<(FuncId, String)>> = path
.iter()
.map(|func| {
global
.decl_of(SymbolId::new(func.raw()))
.map(|decl| (*func, decl.name.clone()))
})
.collect();
let named_funcs = named_funcs?;
let mut funcs_by_name: BTreeMap<String, BTreeSet<FuncId>> = BTreeMap::new();
for (func, name) in &named_funcs {
funcs_by_name.entry(name.clone()).or_default().insert(*func);
}
Some(
named_funcs
.into_iter()
.map(|(func, name)| {
if funcs_by_name
.get(&name)
.is_some_and(|distinct| distinct.len() > 1)
{
func_display_name_with_site(ws, global, func)
} else {
name
}
})
.collect(),
)
}
fn same_source_site(a: &FindingMatch, b: &FindingMatch) -> bool {
a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
}
fn finding_match_identity_token(m: &FindingMatch) -> String {
format!("{}@{}:{}:{}", m.rule_id, m.file, m.line, m.column)
}
fn rule_match_identity_token(rule_id: &str, m: &RuleMatch) -> String {
format!("{}@{}:{}:{}", rule_id, m.file, m.line, m.column)
}
fn displayed_chain_key(chain_names: &[String]) -> String {
chain_names
.iter()
.map(|name| name.split('@').next().unwrap_or(name.as_str()))
.collect::<Vec<_>>()
.join("\0")
}
struct SinkSiteKey {
language: String,
file: String,
line: u32,
column: u32,
rule_id: String,
}
fn drop_field_mismatched_inferred_findings(
findings: Vec<CombinedFindingWithChain>,
) -> Vec<CombinedFindingWithChain> {
let mut concrete_chains: AHashMap<(String, Vec<String>, String), ()> = AHashMap::new();
let mut concrete_sink_sites: AHashMap<(String, String, u32, u32, String), ()> = AHashMap::new();
for combined in &findings {
let f = &combined.finding;
if source_is_inferred(&f.source) {
continue;
}
concrete_chains.insert(
(
f.language.clone(),
f.chain_display.clone(),
f.sink.rule_id.clone(),
),
(),
);
concrete_sink_sites.insert(
(
f.language.clone(),
f.sink.file.clone(),
f.sink.line,
f.sink.column,
f.sink.rule_id.clone(),
),
(),
);
}
let keep = |combined: &CombinedFindingWithChain| -> bool {
let f = &combined.finding;
if !source_is_inferred(&f.source) {
return true;
}
let is_class_field = f.source.origin == MatchOrigin::InferredClassField;
let is_unreferenced_entry = f.source.origin == MatchOrigin::InferredUnreferencedParameter;
let same_sink_site_covered = concrete_sink_sites.contains_key(&(
f.language.clone(),
f.sink.file.clone(),
f.sink.line,
f.sink.column,
f.sink.rule_id.clone(),
));
if is_class_field && same_sink_site_covered {
return false;
}
if is_unreferenced_entry && same_sink_site_covered {
return false;
}
let same_chain_covered = {
let key = (
f.language.clone(),
f.chain_display.clone(),
f.sink.rule_id.clone(),
);
concrete_chains.contains_key(&key)
};
if !same_chain_covered && !same_sink_site_covered {
return true;
}
let Some(field) = inferred_source_field_name(&f.source.text) else {
return true;
};
inferred_field_mentioned_in_sink_args(&f.sink, field)
};
let mut kept: Vec<CombinedFindingWithChain> = Vec::new();
let mut equivalent_members: Vec<(SinkSiteKey, String)> = Vec::new();
for combined in findings {
if keep(&combined) {
kept.push(combined);
continue;
}
let f = &combined.finding;
let field_matches = inferred_source_field_name(&f.source.text)
.is_some_and(|field| inferred_field_mentioned_in_sink_args(&f.sink, field));
if field_matches {
equivalent_members.push((
SinkSiteKey {
language: f.language.clone(),
file: f.sink.file.clone(),
line: f.sink.line,
column: f.sink.column,
rule_id: f.sink.rule_id.clone(),
},
f.finding_id.clone(),
));
}
}
for (site, member_id) in equivalent_members {
let host = kept.iter_mut().find(|combined| {
let f = &combined.finding;
!source_is_inferred(&f.source)
&& f.language == site.language
&& f.sink.file == site.file
&& f.sink.line == site.line
&& f.sink.column == site.column
&& f.sink.rule_id == site.rule_id
});
if let Some(host) = host {
if !host.member_finding_ids.contains(&member_id) {
host.member_finding_ids.push(member_id);
}
}
}
kept
}
fn inferred_field_mentioned_in_sink_args(sink: &FindingMatch, field: &str) -> bool {
let sink_arg_text = sink
.tainted_args
.iter()
.map(|arg| arg.value_text.as_str())
.collect::<Vec<_>>()
.join(" ");
sink_arg_text
.split(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
.any(|t| t == field)
}
fn source_is_inferred(source: &FindingMatch) -> bool {
matches!(
source.origin,
MatchOrigin::InferredUnreferencedParameter
| MatchOrigin::InferredFrameworkParameter
| MatchOrigin::InferredClassField
)
}
fn inferred_source_field_name(text: &str) -> Option<&str> {
let trimmed = text.trim();
let mut start = trimmed.len();
for (idx, ch) in trimmed.char_indices().rev() {
if ch == '_' || ch.is_ascii_alphanumeric() {
start = idx;
} else {
break;
}
}
let tail = &trimmed[start..];
if !tail
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
{
return None;
}
Some(tail)
}
fn finding_has_flow_class(pack: &Rulepack, finding: &FindingMatch, class: FlowClass) -> bool {
pack.find_rule_by_id(&finding.rule_id)
.and_then(|rule| rule.analysis_semantics.as_ref())
.is_some_and(|semantics| semantics.flow_classes.contains(&class))
}
fn source_preference_rank_for_sink(
pack: &Rulepack,
source: &FindingMatch,
sink: Option<&FindingMatch>,
) -> u8 {
if source_is_inferred(source) {
return 30;
}
let base = match source.trust.as_deref() {
Some("remote") => 0,
Some("service" | "ipc" | "database" | "library") => 5,
Some("local" | "config" | "physical") => 10,
_ => 15,
};
let Some(sink) = sink else { return base };
let sink_is_process = finding_has_flow_class(pack, sink, FlowClass::ProcessExecution);
let sink_is_browser = finding_has_flow_class(pack, sink, FlowClass::BrowserOutput);
let src_is_process_or_cli = finding_has_flow_class(pack, source, FlowClass::ProcessInput);
let src_is_http = finding_has_flow_class(pack, source, FlowClass::HttpInput);
let mut adjusted = base as i16;
if sink_is_process {
if src_is_process_or_cli {
adjusted -= 10;
}
if src_is_http && !src_is_process_or_cli {
adjusted += 10;
}
}
if sink_is_browser {
if src_is_http {
adjusted -= 10;
}
if src_is_process_or_cli && !src_is_http {
adjusted += 10;
}
}
adjusted.clamp(0, 255) as u8
}
fn source_specificity_rank(pack: &Rulepack, source: &FindingMatch) -> u8 {
pack.find_rule_by_id(&source.rule_id)
.and_then(|rule| rule.analysis_semantics.as_ref())
.and_then(|semantics| semantics.source_specificity_rank)
.unwrap_or(2)
}
fn source_reporting_rank(pack: &Rulepack, source: &FindingMatch) -> u8 {
pack.find_rule_by_id(&source.rule_id)
.and_then(|rule| rule.analysis_semantics.as_ref())
.and_then(|semantics| semantics.source_reporting_rank)
.unwrap_or(0)
}
fn source_rule_allows_sink_tag(pack: &Rulepack, source_rule_id: &str, sink_rule: &Rule) -> bool {
let Some(source_rule) = pack.find_rule_by_id(source_rule_id) else {
return true;
};
source_rule.constraints.iter().all(|constraint| match constraint {
ConstraintKind::SinkTagIn { sink_tag_in } => sink_rule
.tag
.as_deref()
.is_some_and(|tag| sink_tag_in.iter().any(|allowed| allowed == tag)),
_ => true,
})
}
fn same_sink_site(a: &FindingMatch, b: &FindingMatch) -> bool {
a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
}
#[cfg(test)]
fn combine_findings_by_source_flow(
findings: Vec<FindingWithChain>,
pack: &Rulepack,
) -> Vec<CombinedFindingWithChain> {
combine_route_findings_by_sink(
findings.into_iter().map(combined_from_raw_finding).collect(),
pack,
)
}
fn combined_from_raw_finding(item: FindingWithChain) -> CombinedFindingWithChain {
CombinedFindingWithChain {
finding: item.finding,
chain_funcs: item.chain_funcs,
additional_sources: Vec::new(),
additional_sinks: Vec::new(),
member_finding_ids: Vec::new(),
}
}
fn combine_route_findings_by_sink(
mut findings: Vec<CombinedFindingWithChain>,
pack: &Rulepack,
) -> Vec<CombinedFindingWithChain> {
let mut groups: Vec<CombinedFindingWithChain> = Vec::new();
let mut index: AHashMap<String, usize> = AHashMap::new();
findings.sort_by(|a, b| {
let bucket_a = (
&a.finding.language,
finding_is_pattern_evidence(&a.finding),
&a.finding.sink.file,
a.finding.sink.line,
a.finding.sink.column,
sink_group_class(&a.finding.sink),
a.finding.sink.text.as_str(),
);
let bucket_b = (
&b.finding.language,
finding_is_pattern_evidence(&b.finding),
&b.finding.sink.file,
b.finding.sink.line,
b.finding.sink.column,
sink_group_class(&b.finding.sink),
b.finding.sink.text.as_str(),
);
bucket_a
.cmp(&bucket_b)
.then_with(|| primary_status_rank(a.finding.status).cmp(&primary_status_rank(b.finding.status)))
.then_with(|| {
source_preference_rank_for_sink(pack, &a.finding.source, Some(&a.finding.sink)).cmp(
&source_preference_rank_for_sink(pack, &b.finding.source, Some(&b.finding.sink)),
)
})
.then_with(|| {
source_specificity_rank(pack, &a.finding.source)
.cmp(&source_specificity_rank(pack, &b.finding.source))
})
.then_with(|| {
source_reporting_rank(pack, &a.finding.source)
.cmp(&source_reporting_rank(pack, &b.finding.source))
})
.then_with(|| b.chain_funcs.len().cmp(&a.chain_funcs.len()))
.then_with(|| finding_route_taint_width(&a.finding).cmp(&finding_route_taint_width(&b.finding)))
.then_with(|| {
(
a.finding.source.file.as_str(),
a.finding.source.line,
a.finding.source.column,
)
.cmp(&(
b.finding.source.file.as_str(),
b.finding.source.line,
b.finding.source.column,
))
})
.then_with(|| a.finding.source.rule_id.cmp(&b.finding.source.rule_id))
});
bonsai_diagnostics::debug_log!(
"find-group",
"combining {} raw finding(s) into groups",
findings.len()
);
for item in findings {
let key = combined_finding_key(&item);
bonsai_diagnostics::debug_log!(
"find-group",
" finding {} src={} sink={}@{}:{} -> key={:?}",
item.finding.finding_id,
item.finding.source.rule_id,
item.finding.sink.rule_id,
item.finding.sink.file,
item.finding.sink.line,
key
);
if let Some(&idx) = index.get(&key) {
bonsai_diagnostics::debug_log!(
"find-group",
" -> merge into existing group #{} (primary={})",
idx,
groups[idx].finding.finding_id
);
merge_combined_finding_into_group(&mut groups[idx], item);
continue;
}
let idx = groups.len();
index.insert(key, idx);
groups.push(item);
}
for group in &mut groups {
finalize_combined_finding(group);
}
groups
}
fn primary_status_rank(status: FindingStatus) -> u8 {
match status {
FindingStatus::Unsanitized => 0,
FindingStatus::WrongContext => 1,
FindingStatus::Sanitized => 2,
}
}
fn combined_finding_key(item: &CombinedFindingWithChain) -> String {
let f = &item.finding;
let sink_class = sink_group_class(&f.sink);
format!(
"{}\0{}\0{}\0{}\0{}\0{}\0{}",
f.language,
finding_is_pattern_evidence(f),
f.sink.file,
f.sink.line,
f.sink.column,
sink_class,
f.sink.text
)
}
fn finding_is_pattern_evidence(finding: &Finding) -> bool {
finding.source.origin == MatchOrigin::Pattern
}
fn extend_implicit_context_findings(
findings: &mut Vec<FindingWithChain>,
sink_hits: &[RuleMatch],
pack: &Rulepack,
ws: &Workspace,
) {
let context_consumers: Vec<(&Rule, &RuleMatch)> = sink_hits
.iter()
.filter_map(|hit| {
let rule = pack.find_rule_by_id(&hit.rule_id)?;
let context = rule.analysis_semantics.as_ref()?.context_flow.as_ref()?;
(context.role == ContextFlowRole::Consumer).then_some((rule, hit))
})
.collect();
if context_consumers.is_empty() {
return;
}
struct SanitizedContextRewrite {
channel: String,
language: String,
sink: RuleMatch,
targets: AHashSet<String>,
}
let sanitized_context_rewrites: Vec<SanitizedContextRewrite> = findings
.iter()
.filter_map(|item| {
if item.finding.status != FindingStatus::Sanitized {
return None;
}
let rule = pack.find_rule_by_id(&item.finding.sink.rule_id)?;
let context = rule.analysis_semantics.as_ref()?.context_flow.as_ref()?;
if context.role != ContextFlowRole::Producer
|| !context.sanitized_rewrite_clears_channel
|| !context
.rewrite_source_rule_ids
.iter()
.any(|rule_id| rule_id == &item.finding.source.rule_id)
{
return None;
}
let sink = sink_hits.iter().find(|hit| {
hit.rule_id == item.finding.sink.rule_id
&& hit.file == item.finding.sink.file
&& hit.line == item.finding.sink.line
&& hit.column == item.finding.sink.column
})?;
let targets = item
.finding
.sink
.tainted_args
.iter()
.flat_map(tainted_arg_target_keys)
.collect();
Some(SanitizedContextRewrite {
channel: context.channel.clone(),
language: item.finding.language.clone(),
sink: sink.clone(),
targets,
})
})
.collect();
let producer_flows: Vec<FindingWithChain> = findings
.iter()
.filter(|item| {
item.finding.status == FindingStatus::Unsanitized
&& pack
.find_rule_by_id(&item.finding.sink.rule_id)
.and_then(|rule| rule.analysis_semantics.as_ref())
.and_then(|semantics| semantics.context_flow.as_ref())
.is_some_and(|context| context.role == ContextFlowRole::Producer)
})
.cloned()
.collect();
if producer_flows.is_empty() {
return;
}
let mut existing_ids: AHashSet<String> = findings
.iter()
.map(|item| item.finding.finding_id.clone())
.collect();
let mut consumed_producer_finding_ids: AHashSet<String> = AHashSet::new();
for producer_flow in &producer_flows {
let Some(producer_context) = pack
.find_rule_by_id(&producer_flow.finding.sink.rule_id)
.and_then(|rule| rule.analysis_semantics.as_ref())
.and_then(|semantics| semantics.context_flow.as_ref())
else {
continue;
};
let mut emitted_for_producer = false;
for &(sink_rule, consumer_sink) in &context_consumers {
let Some(consumer_context) = sink_rule
.analysis_semantics
.as_ref()
.and_then(|semantics| semantics.context_flow.as_ref())
else {
continue;
};
if consumer_context.channel != producer_context.channel
|| consumer_sink.language != producer_flow.finding.language
{
continue;
}
let rewritten_before_consumer = sanitized_context_rewrites.iter().any(|rewrite| {
rewrite.channel == consumer_context.channel
&& rewrite.language == consumer_sink.language
&& sanitized_context_rewrite_covers_consumer(
ws,
&rewrite.sink,
consumer_sink,
&rewrite.targets,
)
&& !producer_flows.iter().any(|later| {
if later.finding.finding_id == producer_flow.finding.finding_id
|| later.finding.status != FindingStatus::Unsanitized
{
return false;
}
let Some(later_rule) = pack.find_rule_by_id(&later.finding.sink.rule_id) else {
return false;
};
let Some(later_context) = later_rule
.analysis_semantics
.as_ref()
.and_then(|semantics| semantics.context_flow.as_ref())
else {
return false;
};
if later_context.role != ContextFlowRole::Producer
|| later_context.channel != consumer_context.channel
{
return false;
}
sink_hits.iter().any(|hit| {
hit.rule_id == later.finding.sink.rule_id
&& hit.file == consumer_sink.file
&& hit.line == later.finding.sink.line
&& hit.column == later.finding.sink.column
&& hit.span.start > rewrite.sink.span.end
&& hit.span.end <= consumer_sink.span.start
})
})
});
if rewritten_before_consumer {
emitted_for_producer = true;
continue;
}
let mut sink_match = FindingMatch::from_rule_match(consumer_sink, sink_rule);
sink_match.tainted_args.push(TaintedArgInfo {
index: usize::MAX,
value_text: consumer_context.value_label.clone(),
..TaintedArgInfo::default()
});
let mut chain_display = producer_flow.finding.chain_display.clone();
if let Some(sink_fn) = consumer_sink.enclosing_fn.as_ref() {
if !chain_display.iter().any(|name| name == sink_fn) {
chain_display.push(sink_fn.clone());
}
}
let mut chain_funcs = producer_flow.chain_funcs.clone();
if let Some(sink_func) = func_id_for_match(ws, consumer_sink) {
if !chain_funcs.contains(&sink_func) {
chain_funcs.push(sink_func);
}
}
let mut taint_path = producer_flow.finding.taint_path.clone();
taint_path.push(TaintPropagationStep {
caller: consumer_sink
.enclosing_fn
.clone()
.unwrap_or_else(|| "<context-consumer>".to_string()),
callee: consumer_sink.match_text.clone(),
file: consumer_sink.file.clone(),
line: consumer_sink.line,
column: consumer_sink.column,
tainted_args: vec![TaintPropagationArg {
index: usize::MAX,
value_text: consumer_context.value_label.clone(),
param_name: consumer_context.parameter_name.clone(),
}],
});
let group_id = group_id_for_taint_path(&chain_display, &taint_path);
let flow_id = flow_id_for_taint_path(&chain_display, &taint_path);
let source_identity = finding_match_identity_token(&producer_flow.finding.source);
let sink_identity = finding_match_identity_token(&sink_match);
let finding_id = compute_finding_id(
&source_identity,
&sink_identity,
&group_id,
&producer_flow.finding.language,
);
if !existing_ids.insert(finding_id.clone()) {
continue;
}
let root = ws.db().workspace_root();
let test_patterns = &pack.metadata.test_path_patterns;
let from_test =
path_is_test_file_with_root(
root.as_deref(),
&producer_flow.finding.source.file,
test_patterns,
) || path_is_test_file_with_root(root.as_deref(), &sink_match.file, test_patterns)
|| taint_path
.iter()
.any(|step| path_is_test_file_with_root(root.as_deref(), &step.file, test_patterns));
findings.push(FindingWithChain {
finding: Finding {
finding_id,
language: producer_flow.finding.language.clone(),
source: producer_flow.finding.source.clone(),
sink: sink_match,
sanitizers_seen: producer_flow.finding.sanitizers_seen.clone(),
taint_transforms_seen: producer_flow.finding.taint_transforms_seen.clone(),
group_id: Some(group_id),
representative_flow_id: Some(flow_id),
analysis_complete: producer_flow.finding.analysis_complete,
analysis_incomplete_reasons: producer_flow.finding.analysis_incomplete_reasons.clone(),
chain_display,
taint_path,
alternate_flows: Vec::new(),
hops: Vec::new(),
tag: sink_rule.tag.clone(),
severity: sink_rule.severity,
precision: precision_label(Precision::Narrowed).to_string(),
cwe: sink_rule.cwe.clone(),
owasp: sink_rule.owasp.clone(),
status: FindingStatus::Unsanitized,
from_test,
},
chain_funcs,
});
emitted_for_producer = true;
}
if emitted_for_producer {
consumed_producer_finding_ids.insert(producer_flow.finding.finding_id.clone());
}
}
if !consumed_producer_finding_ids.is_empty() {
findings.retain(|item| !consumed_producer_finding_ids.contains(&item.finding.finding_id));
}
}
fn sink_group_class(sink: &FindingMatch) -> &str {
sink.tag
.as_deref()
.or(sink.category.as_deref())
.unwrap_or(sink.rule_id.as_str())
}
fn merge_combined_finding_into_group(
group: &mut CombinedFindingWithChain,
mut incoming: CombinedFindingWithChain,
) {
let member_id = incoming.finding.finding_id.clone();
let additional_sources = std::mem::take(&mut incoming.additional_sources);
let additional_sinks = std::mem::take(&mut incoming.additional_sinks);
let member_finding_ids = std::mem::take(&mut incoming.member_finding_ids);
merge_finding_into_group(group, incoming.finding, member_id);
for source in additional_sources {
if !same_source_site(&group.finding.source, &source)
&& !group
.additional_sources
.iter()
.any(|existing| same_source_site(existing, &source))
{
group.additional_sources.push(source);
}
}
for sink in additional_sinks {
if !same_sink_site(&group.finding.sink, &sink)
&& !group
.additional_sinks
.iter()
.any(|existing| same_sink_site(existing, &sink))
{
group.additional_sinks.push(sink);
}
}
for member_id in member_finding_ids {
if member_id != group.finding.finding_id && !group.member_finding_ids.contains(&member_id) {
group.member_finding_ids.push(member_id);
}
}
}
fn merge_finding_into_group(group: &mut CombinedFindingWithChain, mut incoming: Finding, member_id: String) {
if group.finding.finding_id != member_id && !group.member_finding_ids.contains(&member_id) {
group.member_finding_ids.push(member_id);
}
if !same_source_site(&group.finding.source, &incoming.source)
&& !group
.additional_sources
.iter()
.any(|source| same_source_site(source, &incoming.source))
{
group.additional_sources.push(incoming.source.clone());
}
let incoming_flow = AlternateTaintFlow {
source: incoming.source.clone(),
sink_tainted_args: incoming.sink.tainted_args.clone(),
sanitizers_seen: incoming.sanitizers_seen.clone(),
taint_transforms_seen: incoming.taint_transforms_seen.clone(),
flow_id: incoming.representative_flow_id.clone(),
chain_display: incoming.chain_display.clone(),
taint_path: incoming.taint_path.clone(),
status: incoming.status,
precision: incoming.precision.clone(),
};
if incoming_flow.flow_id != group.finding.representative_flow_id
&& !group
.finding
.alternate_flows
.iter()
.any(|flow| flow.flow_id == incoming_flow.flow_id)
{
group.finding.alternate_flows.push(incoming_flow);
}
for flow in std::mem::take(&mut incoming.alternate_flows) {
if flow.flow_id != group.finding.representative_flow_id
&& !group
.finding
.alternate_flows
.iter()
.any(|existing| existing.flow_id == flow.flow_id)
{
group.finding.alternate_flows.push(flow);
}
}
if !same_sink_site(&group.finding.sink, &incoming.sink)
&& !group
.additional_sinks
.iter()
.any(|sink| same_sink_site(sink, &incoming.sink))
{
group.additional_sinks.push(incoming.sink.clone());
}
group.finding.severity = max_severity(group.finding.severity, incoming.severity);
group.finding.tag = merge_tag(group.finding.tag.clone(), incoming.tag.as_deref());
merge_unique(&mut group.finding.cwe, incoming.cwe);
merge_unique(&mut group.finding.owasp, incoming.owasp);
merge_analysis_completeness(
&mut group.finding.analysis_complete,
&mut group.finding.analysis_incomplete_reasons,
incoming.analysis_complete,
incoming.analysis_incomplete_reasons,
);
group.finding.from_test &= incoming.from_test;
group.finding.status = group.finding.status.merge(incoming.status);
}
fn merge_analysis_completeness(
current_complete: &mut bool,
current_reasons: &mut Vec<String>,
incoming_complete: bool,
incoming_reasons: Vec<String>,
) {
if !incoming_complete {
*current_complete = false;
}
merge_unique(current_reasons, incoming_reasons);
current_reasons.sort();
current_reasons.dedup();
if !current_reasons.is_empty() {
*current_complete = false;
}
}
fn finalize_combined_finding(group: &mut CombinedFindingWithChain) {
if !group.member_finding_ids.contains(&group.finding.finding_id) {
group
.member_finding_ids
.insert(0, group.finding.finding_id.clone());
}
group.additional_sinks.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then_with(|| a.rule_id.cmp(&b.rule_id))
.then_with(|| (a.file.as_str(), a.line, a.column).cmp(&(b.file.as_str(), b.line, b.column)))
});
let primary_source = group.finding.source.clone();
let mut additional_sources: Vec<FindingMatch> = std::mem::take(&mut group.additional_sources)
.into_iter()
.filter(|source| !same_source_site(&primary_source, source))
.collect();
additional_sources.sort_by(|a, b| {
(a.file.as_str(), a.line, a.column)
.cmp(&(b.file.as_str(), b.line, b.column))
.then_with(|| a.rule_id.cmp(&b.rule_id))
});
group.additional_sources = additional_sources;
group.finding.alternate_flows.sort_by(|a, b| {
alternate_route_taint_width(a)
.cmp(&alternate_route_taint_width(b))
.then_with(|| a.flow_id.cmp(&b.flow_id))
.then_with(|| a.source.file.cmp(&b.source.file))
.then_with(|| a.source.line.cmp(&b.source.line))
.then_with(|| a.source.column.cmp(&b.source.column))
.then_with(|| a.source.rule_id.cmp(&b.source.rule_id))
});
let primary_route = AlternateTaintFlow {
source: group.finding.source.clone(),
sink_tainted_args: group.finding.sink.tainted_args.clone(),
sanitizers_seen: group.finding.sanitizers_seen.clone(),
taint_transforms_seen: group.finding.taint_transforms_seen.clone(),
flow_id: group.finding.representative_flow_id.clone(),
chain_display: group.finding.chain_display.clone(),
taint_path: group.finding.taint_path.clone(),
status: group.finding.status,
precision: group.finding.precision.clone(),
};
let mut retained_routes: Vec<AlternateTaintFlow> = Vec::new();
for route in std::mem::take(&mut group.finding.alternate_flows) {
if route.flow_id == primary_route.flow_id
|| route_is_argument_superset_of(&route, &primary_route)
|| retained_routes
.iter()
.any(|kept| route_is_argument_superset_of(&route, kept))
{
continue;
}
retained_routes.push(route);
}
group.finding.alternate_flows = retained_routes;
let group_id = group
.finding
.group_id
.clone()
.unwrap_or_else(|| group.finding.representative_flow_id.clone().unwrap_or_default());
let sink_token = all_sink_matches(group)
.iter()
.map(finding_match_identity_token)
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
.join("+");
let source_token = all_source_matches(group)
.iter()
.map(finding_match_identity_token)
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
.join("+");
group.finding.finding_id =
compute_finding_id(&source_token, &sink_token, &group_id, &group.finding.language);
group.member_finding_ids.sort();
group.member_finding_ids.dedup();
}
fn all_source_matches(group: &CombinedFindingWithChain) -> Vec<FindingMatch> {
let mut sources = Vec::with_capacity(1 + group.additional_sources.len());
sources.push(group.finding.source.clone());
sources.extend(group.additional_sources.iter().cloned());
sources
}
fn all_sink_matches(group: &CombinedFindingWithChain) -> Vec<FindingMatch> {
let mut sinks = Vec::with_capacity(1 + group.additional_sinks.len());
sinks.push(group.finding.sink.clone());
sinks.extend(group.additional_sinks.iter().cloned());
sinks
}
fn finding_route_taint_width(finding: &Finding) -> usize {
finding
.taint_path
.iter()
.map(|step| step.tainted_args.len())
.sum::<usize>()
.saturating_add(finding.sink.tainted_args.len())
}
fn alternate_route_taint_width(flow: &AlternateTaintFlow) -> usize {
flow.taint_path
.iter()
.map(|step| step.tainted_args.len())
.sum::<usize>()
.saturating_add(flow.sink_tainted_args.len())
}
fn route_is_argument_superset_of(broader: &AlternateTaintFlow, narrower: &AlternateTaintFlow) -> bool {
if !same_source_site(&broader.source, &narrower.source)
|| broader.chain_display != narrower.chain_display
|| broader.taint_path.len() != narrower.taint_path.len()
{
return false;
}
let same_sites = broader
.taint_path
.iter()
.zip(&narrower.taint_path)
.all(|(broad, narrow)| {
broad.caller == narrow.caller
&& broad.callee == narrow.callee
&& broad.file == narrow.file
&& broad.line == narrow.line
&& broad.column == narrow.column
});
if !same_sites {
return false;
}
let path_is_superset = broader
.taint_path
.iter()
.zip(&narrower.taint_path)
.all(|(broad, narrow)| propagation_args_are_subset(&narrow.tainted_args, &broad.tainted_args));
let sink_is_superset =
tainted_arg_infos_are_subset(&narrower.sink_tainted_args, &broader.sink_tainted_args);
let strictly_broader = alternate_route_taint_width(broader) > alternate_route_taint_width(narrower);
path_is_superset && sink_is_superset && strictly_broader
}
fn propagation_args_are_subset(subset: &[TaintPropagationArg], superset: &[TaintPropagationArg]) -> bool {
subset.iter().all(|item| {
superset.iter().any(|candidate| {
candidate.index == item.index
&& candidate.value_text == item.value_text
&& candidate.param_name == item.param_name
})
})
}
fn tainted_arg_infos_are_subset(subset: &[TaintedArgInfo], superset: &[TaintedArgInfo]) -> bool {
subset.iter().all(|item| {
superset.iter().any(|candidate| {
candidate.index == item.index
&& candidate.value_text == item.value_text
&& candidate.place == item.place
&& candidate.source_names == item.source_names
})
})
}
fn drop_rulepack_terminal_dominated_findings(
findings: &mut Vec<CombinedFindingWithChain>,
pack: &Rulepack,
ws: Option<&Workspace>,
) {
if findings.len() < 2 {
return;
}
let mut dominated_by = AHashMap::new();
for (idx, downstream) in findings.iter().enumerate() {
if let Some((preferred_idx, _)) = findings
.iter()
.enumerate()
.filter(|(other_idx, preferred)| {
*other_idx != idx && terminal_finding_dominates(preferred, downstream, pack, ws)
})
.max_by_key(|(_, preferred)| sink_terminal_priority(pack, preferred))
{
dominated_by.insert(idx, preferred_idx);
}
}
if dominated_by.is_empty() {
return;
}
let related = dominated_by
.iter()
.map(|(&downstream_idx, &preferred_idx)| {
(
preferred_idx,
findings[downstream_idx].finding.sink.clone(),
findings[downstream_idx].additional_sinks.clone(),
)
})
.collect::<Vec<_>>();
for (preferred_idx, sink, additional) in related {
let preferred = &mut findings[preferred_idx].additional_sinks;
if !preferred.iter().any(|candidate| {
candidate.rule_id == sink.rule_id
&& candidate.file == sink.file
&& candidate.line == sink.line
&& candidate.column == sink.column
}) {
preferred.push(sink);
}
for sink in additional {
if !preferred.iter().any(|candidate| {
candidate.rule_id == sink.rule_id
&& candidate.file == sink.file
&& candidate.line == sink.line
&& candidate.column == sink.column
}) {
preferred.push(sink);
}
}
}
let mut next_idx = 0usize;
findings.retain(|_| {
let keep = !dominated_by.contains_key(&next_idx);
next_idx = next_idx.saturating_add(1);
keep
});
}
fn terminal_finding_dominates(
preferred: &CombinedFindingWithChain,
downstream: &CombinedFindingWithChain,
pack: &Rulepack,
ws: Option<&Workspace>,
) -> bool {
let preferred_finding = &preferred.finding;
let downstream_finding = &downstream.finding;
let preferred_priority = sink_terminal_priority(pack, preferred);
let downstream_priority = sink_terminal_priority(pack, downstream);
preferred_priority > downstream_priority
&& preferred_priority > 0
&& preferred_finding.language == downstream_finding.language
&& preferred_finding.status == downstream_finding.status
&& same_source_site(&preferred_finding.source, &downstream_finding.source)
&& (function_chain_is_strict_prefix(&preferred.chain_funcs, &downstream.chain_funcs)
|| ws.is_some_and(|ws| preferred_helper_return_feeds_transport(preferred, downstream, ws)))
}
fn preferred_helper_return_feeds_transport(
preferred: &CombinedFindingWithChain,
transport: &CombinedFindingWithChain,
ws: &Workspace,
) -> bool {
if !function_chain_is_strict_prefix(&transport.chain_funcs, &preferred.chain_funcs) {
return false;
}
let (Some(&caller_func), Some(&preferred_func)) =
(transport.chain_funcs.last(), preferred.chain_funcs.last())
else {
return false;
};
if caller_func == preferred_func {
return false;
}
let Some(caller) = ws.exact_decl(SymbolId::new(caller_func.raw())) else {
return false;
};
let tainted_targets = transport
.finding
.sink
.tainted_args
.iter()
.flat_map(tainted_arg_target_keys)
.collect::<AHashSet<_>>();
if tainted_targets.is_empty() {
return false;
}
fn call_span_at_location(
events: &[FlowEvent],
line: u32,
column: u32,
span_map: &bonsai_common::SpanMap,
) -> Option<Span> {
for event in events {
match event {
FlowEvent::Call { span, .. } => {
let location = span_map.line_col(span.start);
if location.line == line && (column == 0 || location.column == column) {
return Some(*span);
}
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
if let Some(span) = call_span_at_location(then_events, line, column, span_map)
.or_else(|| call_span_at_location(else_events, line, column, span_map))
{
return Some(span);
}
}
FlowEvent::Loop { body, .. }
| FlowEvent::Defer { body, .. }
| FlowEvent::Using { body, .. } => {
if let Some(span) = call_span_at_location(body, line, column, span_map) {
return Some(span);
}
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
if let Some(span) = call_span_at_location(body, line, column, span_map)
.or_else(|| call_span_at_location(catch_events, line, column, span_map))
.or_else(|| call_span_at_location(finally_events, line, column, span_map))
{
return Some(span);
}
}
_ => {}
}
}
None
}
fn exact_assignment_feeds(
events: &[FlowEvent],
before: Span,
tainted_targets: &AHashSet<String>,
exact_call_spans: &AHashSet<Span>,
) -> bool {
for event in events {
match event {
FlowEvent::Assign { span, target, .. }
if span.start < before.start
&& clean_overwrite_target_key(target)
.as_ref()
.is_some_and(|target| tainted_targets.contains(target))
&& exact_call_spans.iter().any(|call| span_contains(*span, *call)) =>
{
return true;
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
if exact_assignment_feeds(then_events, before, tainted_targets, exact_call_spans)
|| exact_assignment_feeds(else_events, before, tainted_targets, exact_call_spans)
{
return true;
}
}
FlowEvent::Loop { body, .. }
| FlowEvent::Defer { body, .. }
| FlowEvent::Using { body, .. } => {
if exact_assignment_feeds(body, before, tainted_targets, exact_call_spans) {
return true;
}
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
if exact_assignment_feeds(body, before, tainted_targets, exact_call_spans)
|| exact_assignment_feeds(catch_events, before, tainted_targets, exact_call_spans)
|| exact_assignment_feeds(finally_events, before, tainted_targets, exact_call_spans)
{
return true;
}
}
_ => {}
}
}
false
}
let exact_call_spans = ws
.cached_resolved_call_graph()
.callees_of(caller_func)
.filter(|edge| edge.to == preferred_func && edge.precision.is_semantic())
.map(|edge| edge.span)
.collect::<AHashSet<_>>();
let Some(snapshot) = ws.vfs().snapshot(caller.span.file).ok() else {
return false;
};
let span_map = bonsai_common::cached_span_map_arc(caller.span.file, snapshot.version, &snapshot.text);
let Some(transport_span) = call_span_at_location(
&caller.flow_events,
transport.finding.sink.line,
transport.finding.sink.column,
&span_map,
) else {
return false;
};
!exact_call_spans.is_empty()
&& exact_assignment_feeds(
&caller.flow_events,
transport_span,
&tainted_targets,
&exact_call_spans,
)
}
fn sink_terminal_priority(pack: &Rulepack, finding: &CombinedFindingWithChain) -> u8 {
all_sink_matches(finding)
.into_iter()
.filter_map(|sink| {
pack.find_rule_by_id(&sink.rule_id)
.and_then(|rule| rule.analysis_semantics.as_ref())
.and_then(|semantics| semantics.sink_terminal_priority)
})
.max()
.unwrap_or(0)
}
fn function_chain_is_strict_prefix(prefix: &[FuncId], chain: &[FuncId]) -> bool {
!prefix.is_empty() && chain.len() > prefix.len() && chain.starts_with(prefix)
}
fn drop_dominated_wrapper_findings(findings: &mut Vec<CombinedFindingWithChain>, ws: &Workspace) {
if findings.len() < 2 {
return;
}
let mut dominated = AHashSet::new();
for (idx, candidate) in findings.iter().enumerate() {
if findings
.iter()
.enumerate()
.any(|(other_idx, other)| other_idx != idx && wrapper_finding_is_dominated(candidate, other, ws))
{
dominated.insert(idx);
}
}
if dominated.is_empty() {
return;
}
let mut next_idx = 0usize;
findings.retain(|_| {
let keep = !dominated.contains(&next_idx);
next_idx = next_idx.saturating_add(1);
keep
});
}
fn drop_dominated_receiver_projection_findings(findings: &mut Vec<CombinedFindingWithChain>) {
if findings.len() < 2 {
return;
}
let mut dominated = AHashSet::new();
for (idx, candidate) in findings.iter().enumerate() {
if findings.iter().enumerate().any(|(other_idx, other)| {
other_idx != idx && receiver_projection_finding_is_dominated(candidate, other)
}) {
dominated.insert(idx);
}
}
if dominated.is_empty() {
return;
}
let mut next_idx = 0usize;
findings.retain(|_| {
let keep = !dominated.contains(&next_idx);
next_idx = next_idx.saturating_add(1);
keep
});
}
fn receiver_projection_finding_is_dominated(
receiver_projection: &CombinedFindingWithChain,
direct_arg: &CombinedFindingWithChain,
) -> bool {
let projected = &receiver_projection.finding;
let direct = &direct_arg.finding;
projected.language == direct.language
&& projected.tag == direct.tag
&& projected.status == direct.status
&& same_source_site(&projected.source, &direct.source)
&& same_sink_site(&projected.sink, &direct.sink)
&& cwe_sets_overlap_or_unknown(&projected.cwe, &direct.cwe)
&& sink_args_are_receiver_projection_only(&projected.sink.tainted_args)
&& sink_args_include_direct_argument(&direct.sink.tainted_args)
}
fn sink_args_are_receiver_projection_only(args: &[TaintedArgInfo]) -> bool {
!args.is_empty() && args.iter().all(|arg| arg.index == usize::MAX)
}
fn sink_args_include_direct_argument(args: &[TaintedArgInfo]) -> bool {
args.iter().any(|arg| arg.index != usize::MAX)
}
fn wrapper_finding_is_dominated(
wrapper: &CombinedFindingWithChain,
deeper: &CombinedFindingWithChain,
ws: &Workspace,
) -> bool {
let wrapper_finding = &wrapper.finding;
let deeper_finding = &deeper.finding;
if wrapper_finding.language != deeper_finding.language
|| wrapper_finding.tag != deeper_finding.tag
|| wrapper_finding.status != deeper_finding.status
|| !same_source_site(&wrapper_finding.source, &deeper_finding.source)
|| !cwe_sets_overlap_or_unknown(&wrapper_finding.cwe, &deeper_finding.cwe)
|| deeper_finding.chain_display.len() <= wrapper_finding.chain_display.len()
|| !chain_has_prefix(&deeper_finding.chain_display, &wrapper_finding.chain_display)
|| wrapper_finding.taint_path.len() != 1
|| deeper_finding.taint_path.is_empty()
{
return false;
}
let wrapper_step = &wrapper_finding.taint_path[0];
let deeper_entry = &deeper_finding.taint_path[0];
if wrapper_step.file != deeper_entry.file
|| wrapper_step.line != deeper_entry.line
|| wrapper_step.caller != deeper_entry.caller
{
return false;
}
let nested_callee = display_callee_tail(&deeper_entry.callee);
if nested_callee.is_empty() {
return false;
}
let tainted_indices = wrapper_finding
.sink
.tainted_args
.iter()
.map(|arg| arg.index)
.chain(wrapper_step.tainted_args.iter().map(|arg| arg.index))
.filter(|index| *index != usize::MAX)
.collect::<AHashSet<_>>();
if tainted_indices.is_empty() {
return false;
}
let Some(&wrapper_func) = wrapper.chain_funcs.last() else {
return false;
};
let Some(decl) = ws.exact_decl(SymbolId::new(wrapper_func.raw())) else {
return false;
};
let Some(file_index) = ws.db().decl_index(decl.span.file) else {
return false;
};
let Ok(snapshot) = ws.vfs().snapshot(decl.span.file) else {
return false;
};
let span_map = bonsai_common::cached_span_map_arc(decl.span.file, snapshot.version, &snapshot.text);
let outer_span = flow_call_span_at_location(
&decl.flow_events,
wrapper_finding.sink.line,
wrapper_finding.sink.column,
&span_map,
)
.or_else(|| {
flow_call_span_at_location(
&decl.flow_events,
wrapper_step.line,
wrapper_step.column,
&span_map,
)
});
outer_span.is_some_and(|outer_span| {
flow_argument_calls_named_callee(
&decl.flow_events,
&file_index.call_argument_values,
outer_span,
&tainted_indices,
&nested_callee,
)
})
}
fn flow_call_span_at_location(
events: &[FlowEvent],
line: u32,
column: u32,
span_map: &bonsai_common::SpanMap,
) -> Option<Span> {
for event in events {
match event {
FlowEvent::Call { span, .. } => {
let location = span_map.line_col(span.start);
if location.line == line && (column == 0 || location.column == column) {
return Some(*span);
}
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
if let Some(span) = flow_call_span_at_location(then_events, line, column, span_map)
.or_else(|| flow_call_span_at_location(else_events, line, column, span_map))
{
return Some(span);
}
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
if let Some(span) = flow_call_span_at_location(body, line, column, span_map) {
return Some(span);
}
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
if let Some(span) = flow_call_span_at_location(body, line, column, span_map)
.or_else(|| flow_call_span_at_location(catch_events, line, column, span_map))
.or_else(|| flow_call_span_at_location(finally_events, line, column, span_map))
{
return Some(span);
}
}
_ => {}
}
}
None
}
fn flow_argument_calls_named_callee(
events: &[FlowEvent],
argument_values: &[bonsai_lang_api::CallArgumentValueFact],
outer_span: Span,
tainted_indices: &AHashSet<usize>,
nested_callee: &str,
) -> bool {
fn collect_nested_spans(
argument_values: &[bonsai_lang_api::CallArgumentValueFact],
outer_span: Span,
tainted_indices: &AHashSet<usize>,
out: &mut AHashSet<Span>,
) {
for argument in argument_values {
if argument.call_span == outer_span && tainted_indices.contains(&argument.argument_index) {
out.extend(argument.value_flow.call_sites.iter().copied());
if let Some(span) = argument.direct_call_span {
out.insert(span);
}
}
}
}
fn contains_named_call(events: &[FlowEvent], spans: &AHashSet<Span>, nested_callee: &str) -> bool {
events.iter().any(|event| match event {
FlowEvent::Call { span, name, .. } => {
spans.contains(span) && bonsai_common::qualified_names_match(name, nested_callee)
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
contains_named_call(then_events, spans, nested_callee)
|| contains_named_call(else_events, spans, nested_callee)
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
contains_named_call(body, spans, nested_callee)
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
contains_named_call(body, spans, nested_callee)
|| contains_named_call(catch_events, spans, nested_callee)
|| contains_named_call(finally_events, spans, nested_callee)
}
_ => false,
})
}
let mut nested_spans = AHashSet::new();
collect_nested_spans(argument_values, outer_span, tainted_indices, &mut nested_spans);
!nested_spans.is_empty() && contains_named_call(events, &nested_spans, nested_callee)
}
#[cfg(test)]
mod wrapper_dedup_tests {
use super::*;
fn call(span: Span, name: &str) -> FlowEvent {
FlowEvent::Call {
span,
name: name.to_string(),
receiver: None,
receiver_types: Vec::new(),
call_kind: bonsai_lang_api::CallKind::Function,
args: Vec::new(),
}
}
fn argument_fact(
outer_span: Span,
nested_span: Span,
argument_index: usize,
) -> bonsai_lang_api::CallArgumentValueFact {
bonsai_lang_api::CallArgumentValueFact {
call_span: outer_span,
argument_index,
argument_span: nested_span,
direct_call_span: Some(nested_span),
value_kind: None,
inline_callback_params: Vec::new(),
value_flow: bonsai_lang_api::ExpressionFlow {
call_sites: vec![nested_span],
..bonsai_lang_api::ExpressionFlow::default()
},
static_value: None,
exact_static_aggregate_fields: Vec::new(),
exact_static_sequence_values: None,
}
}
#[test]
fn wrapper_dedup_uses_compiler_argument_call_identity() {
let file = FileId::new(0);
let outer = Span::new(file, 10, 40);
let nested = Span::new(file, 18, 32);
let events = vec![call(outer, "send"), call(nested, "render")];
let facts = vec![argument_fact(outer, nested, 0)];
let tainted = AHashSet::from_iter([0]);
assert!(flow_argument_calls_named_callee(
&events, &facts, outer, &tainted, "render"
));
assert!(
!flow_argument_calls_named_callee(&events, &facts, outer, &tainted, "escape"),
"a different nested callee must not be inferred from rendered argument text"
);
}
#[test]
fn wrapper_dedup_requires_the_tainted_argument_slot() {
let file = FileId::new(0);
let outer = Span::new(file, 10, 40);
let nested = Span::new(file, 18, 32);
let events = vec![call(outer, "send"), call(nested, "render")];
let facts = vec![argument_fact(outer, nested, 1)];
let tainted = AHashSet::from_iter([0]);
assert!(
!flow_argument_calls_named_callee(&events, &facts, outer, &tainted, "render"),
"an untainted sibling argument must not dominate the wrapper finding"
);
}
}
fn chain_has_prefix(chain: &[String], prefix: &[String]) -> bool {
!prefix.is_empty() && chain.len() > prefix.len() && chain.iter().zip(prefix).all(|(a, b)| a == b)
}
fn cwe_sets_overlap_or_unknown(left: &[String], right: &[String]) -> bool {
left.is_empty() || right.is_empty() || left.iter().any(|item| right.iter().any(|other| other == item))
}
fn display_callee_tail(name: &str) -> String {
let without_site = name.split('@').next().unwrap_or(name);
bonsai_common::short_qualified_tail(without_site)
.trim()
.trim_end_matches("()")
.to_string()
}
fn max_severity(a: Option<Severity>, b: Option<Severity>) -> Option<Severity> {
match (a, b) {
(Some(a), Some(b)) => Some(a.max(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
fn merge_tag(current: Option<String>, incoming: Option<&str>) -> Option<String> {
match (current, incoming) {
(None, None) => None,
(Some(tag), None) => Some(tag),
(None, Some(tag)) => Some(tag.to_string()),
(Some(tag), Some(next)) if tag == next => Some(tag),
(Some(_), Some(_)) => Some("multiple".to_string()),
}
}
fn merge_unique(dst: &mut Vec<String>, src: Vec<String>) {
for value in src {
if !dst.contains(&value) {
dst.push(value);
}
}
}
fn spans_share_enclosing_loop(ws: &Workspace, sink_func: FuncId, source_span: Span, sink_span: Span) -> bool {
let Some(decl) = ws.exact_decl(SymbolId::new(sink_func.raw())) else {
return false;
};
spans_share_enclosing_loop_in_events(&decl.flow_events, source_span, sink_span)
}
fn spans_share_enclosing_loop_in_events(
events: &[bonsai_lang_api::FlowEvent],
source_span: Span,
sink_span: Span,
) -> bool {
use bonsai_lang_api::FlowEvent;
for event in events {
match event {
FlowEvent::Loop { span, body, .. } => {
if spans_share_enclosing_loop_in_events(body, source_span, sink_span) {
return true;
}
if span_contains(*span, source_span) && span_contains(*span, sink_span) {
return true;
}
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
if spans_share_enclosing_loop_in_events(then_events, source_span, sink_span)
|| spans_share_enclosing_loop_in_events(else_events, source_span, sink_span)
{
return true;
}
}
FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
if spans_share_enclosing_loop_in_events(body, source_span, sink_span) {
return true;
}
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
if spans_share_enclosing_loop_in_events(body, source_span, sink_span)
|| spans_share_enclosing_loop_in_events(catch_events, source_span, sink_span)
|| spans_share_enclosing_loop_in_events(finally_events, source_span, sink_span)
{
return true;
}
}
_ => {}
}
}
false
}
fn sanitizer_call_overlaps_tainted_call(san: &RuleMatch, tainted_call_spans: &AHashSet<Span>) -> bool {
tainted_call_spans
.iter()
.any(|span| spans_overlap(*span, san.span))
}
fn sanitizer_is_nested_in_tainted_sink_arg(
ws: &Workspace,
sink_func: FuncId,
src: &RuleMatch,
san: &RuleMatch,
snk: &RuleMatch,
sink_tainted_args: &[TaintedArgInfo],
) -> bool {
if san.span.file != snk.span.file || sink_tainted_args.is_empty() {
return false;
}
let Some(decl) = ws.exact_decl(SymbolId::new(sink_func.raw())) else {
return false;
};
let Some(FlowEvent::Call {
span: sink_call_span,
args: sink_args,
..
}) = find_call_event_at(&decl.flow_events, snk.span)
else {
return false;
};
let Some(FlowEvent::Call {
span: sanitizer_call_span,
name: sanitizer_call_name,
receiver: sanitizer_receiver,
args: sanitizer_args,
..
}) = find_call_event_at(&decl.flow_events, san.span)
else {
return false;
};
if sanitizer_call_span == sink_call_span
|| clean_overwrite_callee_tail(sanitizer_call_name) != clean_overwrite_callee_tail(&san.match_text)
{
return false;
}
let mut sanitizer_values = call_arg_value_keys(sanitizer_args);
sanitizer_values.extend(sanitizer_receiver.as_deref().and_then(clean_overwrite_target_key));
sanitizer_values.extend(
sanitizer_receiver
.as_deref()
.and_then(source_expr_base_identifier)
.and_then(clean_overwrite_target_key),
);
if sanitizer_values.is_empty() {
return false;
}
let source_carrier = source_expr_base_identifier(&src.match_text).and_then(clean_overwrite_target_key);
sink_tainted_args.iter().any(|tainted| {
let Some(sink_arg) = sink_args.get(tainted.index) else {
return false;
};
if !span_contains(sink_arg.span, *sanitizer_call_span) {
return false;
}
let sink_values = call_arg_value_keys(std::slice::from_ref(sink_arg));
let wraps_original_carrier = source_carrier
.as_ref()
.is_some_and(|carrier| sanitizer_values.contains(carrier));
wraps_original_carrier || (!sink_values.is_empty() && sink_values.is_subset(&sanitizer_values))
})
}
fn sanitizer_is_sole_nested_value_call_in_return(
ws: &Workspace,
sink_func: FuncId,
san: &RuleMatch,
snk: &RuleMatch,
sink_rule: &Rule,
) -> bool {
if sink_rule.match_spec.kind != crate::rule::MatchKind::Return || san.span.file != snk.span.file {
return false;
}
let Some(decl) = ws.exact_decl(SymbolId::new(sink_func.raw())) else {
return false;
};
let Some(FlowEvent::Call {
span: sanitizer_span,
receiver,
args,
..
}) = find_call_event_at(&decl.flow_events, san.span)
else {
return false;
};
let mut dynamic_inputs = call_arg_value_keys(args);
dynamic_inputs.extend(receiver.as_deref().and_then(clean_overwrite_target_key));
if dynamic_inputs.is_empty() {
return false;
}
let Some(value_flow) = return_value_flow_containing_span(&decl.flow_events, snk.span) else {
return false;
};
value_flow.source_names.is_empty()
&& value_flow.aggregate_fields.is_empty()
&& value_flow.tuple_items.is_empty()
&& value_flow.spreads.is_empty()
&& matches!(value_flow.call_sites.as_slice(), [call_site] if spans_overlap(*call_site, *sanitizer_span))
}
struct HelperSanitizerReturnContext<'a> {
ws: &'a Workspace,
call_graph: &'a bonsai_callgraph::ResolvedCallGraph,
helper_func: FuncId,
sink_func: FuncId,
sanitizer: &'a RuleMatch,
sink: &'a RuleMatch,
sink_tainted_args: &'a [TaintedArgInfo],
tainted_call_spans: &'a AHashSet<Span>,
}
fn sanitizer_is_helper_return_reaching_tainted_sink_arg(context: HelperSanitizerReturnContext<'_>) -> bool {
let HelperSanitizerReturnContext {
ws,
call_graph,
helper_func,
sink_func,
sanitizer,
sink,
sink_tainted_args,
tainted_call_spans,
} = context;
if helper_func == sink_func
|| sanitizer.span.file == sink.span.file && spans_overlap(sanitizer.span, sink.span)
{
return false;
}
let Some(helper) = ws.exact_decl(SymbolId::new(helper_func.raw())) else {
return false;
};
let Some(FlowEvent::Call {
span: sanitizer_span,
receiver,
args,
..
}) = find_call_event_at(&helper.flow_events, sanitizer.span)
else {
return false;
};
let mut sanitizer_inputs = call_arg_value_keys(args);
sanitizer_inputs.extend(receiver.as_deref().and_then(clean_overwrite_target_key));
let input_indices = helper
.params
.iter()
.enumerate()
.filter_map(|(index, parameter)| sanitizer_inputs.contains(parameter).then_some(index))
.collect::<Vec<_>>();
bonsai_diagnostics::debug_log!(
"security-taint",
"helper_sanitizer_candidate helper={} sanitizer={} input_indices={:?} params={:?}",
helper.name,
sanitizer.rule_id,
input_indices,
helper.params
);
if input_indices.is_empty() || input_indices.len() != helper.params.len() {
return false;
}
let mut returns = Vec::new();
collect_return_value_flows(&helper.flow_events, &mut returns);
let [(_, return_flow)] = returns.as_slice() else {
return false;
};
let direct_sanitizer_return = return_flow.source_names.is_empty()
&& return_flow.aggregate_fields.is_empty()
&& return_flow.tuple_items.is_empty()
&& return_flow.spreads.is_empty()
&& matches!(return_flow.call_sites.as_slice(), [call_site] if spans_overlap(*call_site, *sanitizer_span));
let assigned_sanitizer_return = || {
let place = return_flow.place.as_deref()?.trim();
if place.is_empty()
|| !return_flow.aggregate_fields.is_empty()
|| !return_flow.tuple_items.is_empty()
|| !return_flow.spreads.is_empty()
{
return None;
}
let file_index = ws.exact_decl_index_shared(helper.span.file)?;
let assignment = file_index
.assignment_values
.iter()
.filter(|fact| {
fact.target.as_deref() == Some(place)
&& helper.span.start <= fact.assignment_span.start
&& fact.assignment_span.end <= helper.span.end
})
.max_by_key(|fact| fact.assignment_span.end)?;
let value = &assignment.value_flow;
if !value.source_names.is_empty()
|| !value.aggregate_fields.is_empty()
|| !value.tuple_items.is_empty()
|| !value.spreads.is_empty()
{
return None;
}
let mut call_sites = assignment.call_sites.clone();
call_sites.extend(value.call_sites.iter().copied());
call_sites.sort_by_key(|span| (span.start, span.end));
call_sites.dedup();
matches!(call_sites.as_slice(), [call_site] if spans_overlap(*call_site, *sanitizer_span))
.then_some(())
};
if !direct_sanitizer_return && assigned_sanitizer_return().is_none() {
bonsai_diagnostics::debug_log!(
"security-taint",
"helper_sanitizer_return_rejected helper={} flow={:?}",
helper.name,
return_flow
);
return false;
}
let Some(sink_decl) = ws.exact_decl(SymbolId::new(sink_func.raw())) else {
return false;
};
let Some(sink_index) = ws.exact_decl_index_shared(sink.span.file) else {
return false;
};
let Some(FlowEvent::Call {
span: sink_call_span, ..
}) = find_call_event_at(&sink_decl.flow_events, sink.span)
else {
return false;
};
for sink_argument_index in sink_tainted_args.iter().map(|argument| argument.index) {
let Some(sink_argument) = bonsai_lang_api::call_argument_value_fact(
&sink_index.call_argument_values,
*sink_call_span,
sink_argument_index,
) else {
continue;
};
let mut reaching_calls = Vec::new();
if let Some(span) = sink_argument.direct_call_span {
reaching_calls.push(span);
}
collect_compiler_call_sites_reaching_value(
&sink_index,
&sink_argument.value_flow,
*sink_call_span,
&mut reaching_calls,
&mut AHashSet::new(),
);
reaching_calls = reaching_calls
.into_iter()
.filter_map(|site| match find_call_event_at(&sink_decl.flow_events, site) {
Some(FlowEvent::Call { span, .. }) => Some(*span),
_ => None,
})
.collect();
reaching_calls.sort_by_key(|span| (span.start, span.end));
reaching_calls.dedup();
for helper_call_span in reaching_calls.into_iter().filter(|call_span| {
tainted_call_spans.iter().any(|tainted| {
spans_overlap(*tainted, *call_span)
|| span_contains(*tainted, *call_span)
|| span_contains(*call_span, *tainted)
})
}) {
let targets = call_graph
.callees_of(sink_func)
.filter(|edge| edge.precision.is_semantic() && spans_overlap(edge.span, helper_call_span))
.map(|edge| edge.to)
.collect::<AHashSet<_>>();
if targets.len() != 1 || !targets.contains(&helper_func) {
continue;
}
if input_indices.iter().all(|input_index| {
bonsai_lang_api::call_argument_value_fact(
&sink_index.call_argument_values,
helper_call_span,
*input_index,
)
.is_some_and(|argument| !argument.value_flow.is_empty())
}) {
return true;
}
}
}
false
}
fn helper_functions_reaching_tainted_sink_args(
ws: &Workspace,
call_graph: &bonsai_callgraph::ResolvedCallGraph,
sink_func: FuncId,
sink: &RuleMatch,
sink_tainted_args: &[TaintedArgInfo],
) -> Vec<FuncId> {
let Some(sink_decl) = ws.exact_decl(SymbolId::new(sink_func.raw())) else {
return Vec::new();
};
let Some(sink_index) = ws.exact_decl_index_shared(sink.span.file) else {
return Vec::new();
};
let Some(FlowEvent::Call {
span: sink_call_span, ..
}) = find_call_event_at(&sink_decl.flow_events, sink.span)
else {
return Vec::new();
};
let mut call_sites = Vec::new();
for argument_index in sink_tainted_args.iter().map(|argument| argument.index) {
let Some(argument) = bonsai_lang_api::call_argument_value_fact(
&sink_index.call_argument_values,
*sink_call_span,
argument_index,
) else {
continue;
};
if let Some(span) = argument.direct_call_span {
call_sites.push(span);
}
collect_compiler_call_sites_reaching_value(
&sink_index,
&argument.value_flow,
*sink_call_span,
&mut call_sites,
&mut AHashSet::new(),
);
}
call_sites = call_sites
.into_iter()
.filter_map(|site| match find_call_event_at(&sink_decl.flow_events, site) {
Some(FlowEvent::Call { span, .. }) => Some(*span),
_ => None,
})
.collect();
call_sites.sort_by_key(|span| (span.file.raw(), span.start, span.end));
call_sites.dedup();
let mut helpers = call_graph
.callees_of(sink_func)
.filter(|edge| {
edge.precision.is_semantic()
&& call_sites
.iter()
.any(|call_site| spans_overlap(edge.span, *call_site))
})
.map(|edge| edge.to)
.collect::<Vec<_>>();
helpers.sort_by_key(|function| function.raw());
helpers.dedup();
bonsai_diagnostics::debug_log!(
"security-taint",
"sink_argument_helpers sink={} call_sites={:?} helpers={:?}",
sink.rule_id,
call_sites,
helpers
);
helpers
}
fn collect_return_value_flows<'a>(
events: &'a [FlowEvent],
out: &mut Vec<(Span, &'a bonsai_lang_api::ExpressionFlow)>,
) {
for event in events {
match event {
FlowEvent::Return { span, value_flow, .. } => out.push((*span, value_flow)),
FlowEvent::Branch {
then_events,
else_events,
..
} => {
collect_return_value_flows(then_events, out);
collect_return_value_flows(else_events, out);
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
collect_return_value_flows(body, out);
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
collect_return_value_flows(body, out);
collect_return_value_flows(catch_events, out);
collect_return_value_flows(finally_events, out);
}
_ => {}
}
}
}
fn return_value_flow_containing_span(
events: &[FlowEvent],
target: Span,
) -> Option<&bonsai_lang_api::ExpressionFlow> {
for event in events {
match event {
FlowEvent::Return { span, value_flow, .. }
if span.file == target.file
&& (spans_overlap(*span, target) || span_contains(*span, target)) =>
{
return Some(value_flow);
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
if let Some(flow) = return_value_flow_containing_span(then_events, target)
.or_else(|| return_value_flow_containing_span(else_events, target))
{
return Some(flow);
}
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
if let Some(flow) = return_value_flow_containing_span(body, target) {
return Some(flow);
}
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
if let Some(flow) = return_value_flow_containing_span(body, target)
.or_else(|| return_value_flow_containing_span(catch_events, target))
.or_else(|| return_value_flow_containing_span(finally_events, target))
{
return Some(flow);
}
}
_ => {}
}
}
None
}
fn call_arg_value_keys(args: &[bonsai_lang_api::CallArg]) -> AHashSet<String> {
args.iter()
.flat_map(|arg| {
arg.source_names
.iter()
.map(String::as_str)
.chain(arg.place.as_deref())
})
.filter_map(clean_overwrite_target_key)
.collect()
}
fn xxe_factory_hardening_sanitizes_sink(
ws: &Workspace,
sink_func: FuncId,
metadata: &RulepackMetadata,
sanitizer_rule: Option<&Rule>,
sink_rule: &Rule,
san: &RuleMatch,
snk: &RuleMatch,
) -> bool {
let sink_semantics = sink_rule.analysis_semantics.as_ref();
if !sanitizer_credits_sink_tag(
metadata,
sanitizer_rule.and_then(|rule| rule.tag.as_deref()),
sink_rule.tag.as_deref(),
) || sink_semantics.and_then(|semantics| semantics.sanitizer_attachment_policy)
!= Some(SanitizerAttachmentPolicy::ReceiverFactoryLineage)
|| san.span.file != snk.span.file
|| !match_precedes_or_same(san, snk)
{
return false;
}
let Some(factory_receiver) = receiver_text_from_match(&san.match_text) else {
return false;
};
let Some(sink_receiver) = receiver_text_from_match(&snk.match_text) else {
return false;
};
if factory_receiver == sink_receiver {
return true;
}
builder_created_from_factory_before_sink(
ws,
sink_func,
san.span,
snk.span,
sink_receiver,
factory_receiver,
sink_semantics
.map(|semantics| semantics.receiver_factory_lineage_builders.as_slice())
.unwrap_or_default(),
)
}
fn receiver_text_from_match(text: &str) -> Option<&str> {
let (receiver, _) = text.trim().rsplit_once('.')?;
let receiver = receiver.trim();
(!receiver.is_empty()).then_some(receiver)
}
fn builder_created_from_factory_before_sink(
ws: &Workspace,
sink_func: FuncId,
san_span: Span,
sink_span: Span,
builder_receiver: &str,
factory_receiver: &str,
builder_targets: &[RuleTarget],
) -> bool {
if san_span.file != sink_span.file || san_span.end > sink_span.start {
return false;
}
let Some(decl) = ws.exact_decl(SymbolId::new(sink_func.raw())) else {
return false;
};
let context = BuilderLineageContext {
all_events: &decl.flow_events,
sanitizer_span: san_span,
sink_span,
builder_receiver,
factory_receiver,
builder_targets,
};
builder_available_at_sink(&decl.flow_events, &context, false).unwrap_or(false)
}
struct BuilderLineageContext<'a> {
all_events: &'a [FlowEvent],
sanitizer_span: Span,
sink_span: Span,
builder_receiver: &'a str,
factory_receiver: &'a str,
builder_targets: &'a [RuleTarget],
}
fn builder_available_at_sink(
events: &[FlowEvent],
context: &BuilderLineageContext<'_>,
mut available: bool,
) -> Option<bool> {
for event in events {
match event {
FlowEvent::Assign { span, target, .. } => {
if context.sanitizer_span.end <= span.start
&& span.end <= context.sink_span.start
&& clean_overwrite_target_key(target)
== clean_overwrite_target_key(context.builder_receiver)
&& assignment_uses_factory_builder(
context.all_events,
*span,
context.factory_receiver,
context.builder_targets,
)
{
available = true;
}
}
FlowEvent::Call { span, .. } if spans_overlap(*span, context.sink_span) => {
return Some(available);
}
FlowEvent::Branch {
span,
then_events,
else_events,
..
} if span_contains(*span, context.sink_span) => {
return builder_available_at_sink(then_events, context, available)
.or_else(|| builder_available_at_sink(else_events, context, available));
}
FlowEvent::Loop { span, body, .. }
| FlowEvent::Defer { span, body }
| FlowEvent::Using { span, body, .. }
if span_contains(*span, context.sink_span) =>
{
return builder_available_at_sink(body, context, available);
}
FlowEvent::Try {
span,
body,
catch_events,
finally_events,
..
} if span_contains(*span, context.sink_span) => {
return builder_available_at_sink(body, context, available)
.or_else(|| builder_available_at_sink(catch_events, context, available))
.or_else(|| builder_available_at_sink(finally_events, context, available));
}
_ => {}
}
}
None
}
fn assignment_uses_factory_builder(
events: &[FlowEvent],
assignment_span: Span,
factory_receiver: &str,
builder_targets: &[RuleTarget],
) -> bool {
for event in events {
match event {
FlowEvent::Call {
span, name, receiver, ..
} => {
if (span_contains(assignment_span, *span) || spans_overlap(assignment_span, *span))
&& builder_targets
.iter()
.any(|target| rule_target_matches_call(name, &[], target))
&& receiver.as_deref().and_then(clean_overwrite_target_key)
== clean_overwrite_target_key(factory_receiver)
{
return true;
}
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
if assignment_uses_factory_builder(
then_events,
assignment_span,
factory_receiver,
builder_targets,
) || assignment_uses_factory_builder(
else_events,
assignment_span,
factory_receiver,
builder_targets,
) {
return true;
}
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
if assignment_uses_factory_builder(body, assignment_span, factory_receiver, builder_targets) {
return true;
}
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
if assignment_uses_factory_builder(body, assignment_span, factory_receiver, builder_targets)
|| assignment_uses_factory_builder(
catch_events,
assignment_span,
factory_receiver,
builder_targets,
)
|| assignment_uses_factory_builder(
finally_events,
assignment_span,
factory_receiver,
builder_targets,
)
{
return true;
}
}
_ => {}
}
}
false
}
#[allow(clippy::too_many_arguments)]
fn sanitizer_can_attach(
src: &RuleMatch,
source_func: FuncId,
san: &RuleMatch,
sanitizer_func: FuncId,
snk: &RuleMatch,
sink_func: FuncId,
nested_in_tainted_sink_arg: bool,
dataflow_connected: bool,
post_sink_path_construction_containment: bool,
) -> bool {
if sanitizer_func == source_func && !match_precedes_or_same(src, san) && !dataflow_connected {
return false;
}
if sanitizer_func == sink_func
&& !match_precedes_or_same(san, snk)
&& !nested_in_tainted_sink_arg
&& !post_sink_path_construction_containment
{
return false;
}
true
}
fn sanitizer_assignment_output_feeds_sink_arg(
ws: &Workspace,
sanitizer_func: FuncId,
san: &RuleMatch,
snk: &RuleMatch,
sink_rule: &Rule,
sink_tainted_args: &[TaintedArgInfo],
) -> bool {
if san.span.file != snk.span.file || !match_precedes_or_same(san, snk) {
return false;
}
let target_keys = sanitizer_assignment_sink_target_keys(snk, sink_rule, sink_tainted_args);
if target_keys.is_empty() {
return false;
}
let Some(decl) = ws.exact_decl(SymbolId::new(sanitizer_func.raw())) else {
return false;
};
sanitizer_assignment_output_feeds_sink_arg_in_events(&decl.flow_events, san, &target_keys)
}
fn sanitizer_guarded_value_filter_output_feeds_sink_arg(
ws: &Workspace,
sanitizer_func: FuncId,
san: &RuleMatch,
snk: &RuleMatch,
sink_rule: &Rule,
sink_tainted_args: &[TaintedArgInfo],
) -> bool {
if san.span.file != snk.span.file || !match_precedes_or_same(san, snk) {
return false;
}
let target_keys = sanitizer_assignment_sink_target_keys(snk, sink_rule, sink_tainted_args);
if target_keys.is_empty() {
return false;
}
let Some(decl) = ws.exact_decl(SymbolId::new(sanitizer_func.raw())) else {
return false;
};
let Some(file_index) = ws.exact_decl_index_shared(decl.span.file) else {
return false;
};
file_index.guarded_value_filters.iter().any(|fact| {
fact.function_span == decl.span
&& fact.write_span.start < snk.span.start
&& (spans_overlap(fact.predicate_call_span, san.span)
|| span_contains(fact.predicate_call_span, san.span)
|| span_contains(san.span, fact.predicate_call_span))
&& clean_overwrite_target_key(&fact.output_place)
.is_some_and(|output| target_keys.contains(&output))
})
}
fn sanitizer_assignment_sink_target_keys(
snk: &RuleMatch,
sink_rule: &Rule,
sink_tainted_args: &[TaintedArgInfo],
) -> AHashSet<String> {
let mut target_keys: AHashSet<String> = sink_tainted_args
.iter()
.flat_map(tainted_arg_target_keys)
.collect();
if target_keys.is_empty() && sink_rule.match_spec.kind == MatchKind::Return {
target_keys.extend(clean_overwrite_target_key(&snk.match_text));
}
target_keys
}
fn sanitizer_assignment_output_feeds_sink_arg_in_events(
events: &[FlowEvent],
san: &RuleMatch,
target_keys: &AHashSet<String>,
) -> bool {
let sanitizer_targets = sanitizer_assignment_targets_in_events(events, san);
for event in events {
match event {
FlowEvent::Assign {
span,
target,
source_call,
source_name,
source_call_args,
source_names,
..
} => {
let Some(target_key) = clean_overwrite_target_key(target) else {
continue;
};
if !target_keys.contains(&target_key) {
continue;
}
let direct_sanitizer_assignment = (spans_overlap(*span, san.span)
|| span_contains(*span, san.span))
&& source_call.as_deref().is_some_and(|source_call| {
security_text_matches_source_strict(source_call, &san.match_text)
|| security_text_matches_source_strict(&san.match_text, source_call)
|| spans_overlap(*span, san.span)
});
let assignment_uses_sanitized_local = assignment_sources_include_any(
source_name.as_deref(),
source_call_args,
source_names,
&sanitizer_targets,
);
if direct_sanitizer_assignment || assignment_uses_sanitized_local {
return true;
}
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
if sanitizer_assignment_output_feeds_sink_arg_in_events(then_events, san, target_keys)
|| sanitizer_assignment_output_feeds_sink_arg_in_events(else_events, san, target_keys)
{
return true;
}
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
if sanitizer_assignment_output_feeds_sink_arg_in_events(body, san, target_keys) {
return true;
}
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
if sanitizer_assignment_output_feeds_sink_arg_in_events(body, san, target_keys)
|| sanitizer_assignment_output_feeds_sink_arg_in_events(catch_events, san, target_keys)
|| sanitizer_assignment_output_feeds_sink_arg_in_events(finally_events, san, target_keys)
{
return true;
}
}
_ => {}
}
}
false
}
fn sanitizer_assignment_targets_in_events(events: &[FlowEvent], san: &RuleMatch) -> AHashSet<String> {
let mut targets = AHashSet::new();
collect_sanitizer_assignment_targets(events, san, &mut targets);
targets
}
fn collect_sanitizer_assignment_targets(
events: &[FlowEvent],
san: &RuleMatch,
targets: &mut AHashSet<String>,
) {
for event in events {
match event {
FlowEvent::Assign {
span,
target,
source_call,
..
} => {
let Some(target_key) = clean_overwrite_target_key(target) else {
continue;
};
let source_call_matches = source_call.as_deref().is_some_and(|source_call| {
security_text_matches_source_strict(source_call, &san.match_text)
|| security_text_matches_source_strict(&san.match_text, source_call)
});
if spans_overlap(*span, san.span) || span_contains(*span, san.span) || source_call_matches {
targets.insert(target_key);
}
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
collect_sanitizer_assignment_targets(then_events, san, targets);
collect_sanitizer_assignment_targets(else_events, san, targets);
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
collect_sanitizer_assignment_targets(body, san, targets);
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
collect_sanitizer_assignment_targets(body, san, targets);
collect_sanitizer_assignment_targets(catch_events, san, targets);
collect_sanitizer_assignment_targets(finally_events, san, targets);
}
_ => {}
}
}
}
fn assignment_sources_include_any(
source_name: Option<&str>,
source_call_args: &[String],
source_names: &[String],
candidates: &AHashSet<String>,
) -> bool {
!candidates.is_empty()
&& source_name
.into_iter()
.chain(source_call_args.iter().map(String::as_str))
.chain(source_names.iter().map(String::as_str))
.filter_map(clean_overwrite_target_key)
.any(|source| candidates.contains(&source))
}
struct SanitizerGuardContext<'a> {
ws: &'a Workspace,
sink_tainted_args: &'a [TaintedArgInfo],
}
fn sanitizer_guard_feeds_sink_arg(
context: &SanitizerGuardContext<'_>,
pack: &Rulepack,
sanitizer_func: FuncId,
sanitizer_rule: Option<&Rule>,
san: &RuleMatch,
sanitizer_hits: &[&RuleMatch],
snk: &RuleMatch,
) -> bool {
let Some(sanitizer_rule) = sanitizer_rule else {
return false;
};
let Some(guard) = sanitizer_rule
.analysis_semantics
.as_ref()
.and_then(|semantics| semantics.sanitizer_guard.as_ref())
else {
return false;
};
if san.span.file != snk.span.file || !match_precedes_or_same(san, snk) {
return false;
}
let target_keys: AHashSet<String> = context
.sink_tainted_args
.iter()
.flat_map(tainted_arg_target_keys)
.collect();
if target_keys.is_empty() {
return false;
}
let Some(decl) = context.ws.exact_decl(SymbolId::new(sanitizer_func.raw())) else {
return false;
};
if guard.require_terminal_rejection {
return terminal_type_guards_cover_sink_targets(
context.ws,
&decl,
sanitizer_rule,
guard,
sanitizer_hits,
snk,
&target_keys,
);
}
let guarded = sanitizer_guard_variables_in_events(&decl.flow_events, san, guard);
if guarded.is_empty() {
return false;
}
let guarded_set: AHashSet<String> = guarded.into_iter().collect();
if target_keys.iter().any(|target| guarded_set.contains(target)) {
return true;
}
let receiver_mutation_targets = pack.receiver_mutation_targets(&snk.language);
guarded_variable_feeds_sink_target_in_events(
&decl.flow_events,
san.span,
snk.span,
&guarded_set,
&target_keys,
) || guarded_variable_flows_into_receiver_before_sink(
&decl.flow_events,
san.span,
snk.span,
&guarded_set,
&target_keys,
receiver_mutation_targets,
)
}
fn terminal_type_guards_cover_sink_targets(
ws: &Workspace,
decl: &bonsai_lang_api::Decl,
sanitizer_rule: &Rule,
guard: &SanitizerGuardSemantics,
sanitizer_hits: &[&RuleMatch],
sink: &RuleMatch,
sink_targets: &AHashSet<String>,
) -> bool {
sink_targets.iter().all(|target| {
sanitizer_hits
.iter()
.filter(|candidate| {
candidate.rule_id == sanitizer_rule.id
&& candidate.span.file == sink.span.file
&& match_precedes_or_same(candidate, sink)
})
.any(|candidate| {
let guarded = sanitizer_guard_variables_in_events(&decl.flow_events, candidate, guard);
if !guarded.iter().any(|place| place == target) {
return false;
}
let Some(branch_span) =
terminal_rejection_predicate_guard_span(ws, decl, candidate.span, sink.span)
else {
return false;
};
!place_is_assigned_between(&decl.flow_events, target, branch_span.end, sink.span.start)
})
})
}
fn sanitizer_guard_variables_in_events(
events: &[FlowEvent],
san: &RuleMatch,
guard: &SanitizerGuardSemantics,
) -> Vec<String> {
let mut vars = Vec::new();
collect_sanitizer_guard_variables(events, san, guard, &mut vars);
vars.sort();
vars.dedup();
vars
}
fn collect_sanitizer_guard_variables(
events: &[FlowEvent],
san: &RuleMatch,
guard: &SanitizerGuardSemantics,
vars: &mut Vec<String>,
) {
for event in events {
match event {
FlowEvent::Call {
span, receiver, args, ..
} if spans_overlap(*span, san.span) || span_contains(*span, san.span) => {
if guard.use_receiver {
if let Some(receiver) = receiver.as_deref().and_then(clean_overwrite_target_key) {
vars.push(receiver);
}
}
if guard.all_arguments {
collect_guard_argument_places(args.iter(), vars);
} else {
collect_guard_argument_places(
guard.argument_indices.iter().filter_map(|index| args.get(*index)),
vars,
);
}
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
collect_sanitizer_guard_variables(then_events, san, guard, vars);
collect_sanitizer_guard_variables(else_events, san, guard, vars);
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
collect_sanitizer_guard_variables(body, san, guard, vars);
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
collect_sanitizer_guard_variables(body, san, guard, vars);
collect_sanitizer_guard_variables(catch_events, san, guard, vars);
collect_sanitizer_guard_variables(finally_events, san, guard, vars);
}
_ => {}
}
}
}
fn collect_guard_argument_places<'a>(
arguments: impl Iterator<Item = &'a bonsai_lang_api::CallArg>,
vars: &mut Vec<String>,
) {
for argument in arguments {
if let Some(place) = argument.place.as_deref().and_then(clean_overwrite_target_key) {
vars.push(place);
}
if let Some(value) = clean_overwrite_target_key(&argument.value_text) {
vars.push(value);
}
for source in &argument.source_names {
if let Some(value) = clean_overwrite_target_key(source) {
vars.push(value);
}
}
}
}
fn guarded_variable_feeds_sink_target_in_events(
events: &[FlowEvent],
guard_span: Span,
sink_span: Span,
guarded: &AHashSet<String>,
sink_targets: &AHashSet<String>,
) -> bool {
for event in events {
match event {
FlowEvent::Assign {
span,
target,
source_name,
source_call_args,
source_names,
..
} if span.file == sink_span.file
&& guard_span.end <= span.start
&& span.start <= sink_span.start =>
{
let Some(target_key) = clean_overwrite_target_key(target) else {
continue;
};
if sink_targets.contains(&target_key)
&& assignment_sources_include_any(
source_name.as_deref(),
source_call_args,
source_names,
guarded,
)
{
return true;
}
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
if guarded_variable_feeds_sink_target_in_events(
then_events,
guard_span,
sink_span,
guarded,
sink_targets,
) || guarded_variable_feeds_sink_target_in_events(
else_events,
guard_span,
sink_span,
guarded,
sink_targets,
) {
return true;
}
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
if guarded_variable_feeds_sink_target_in_events(
body,
guard_span,
sink_span,
guarded,
sink_targets,
) {
return true;
}
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
if guarded_variable_feeds_sink_target_in_events(
body,
guard_span,
sink_span,
guarded,
sink_targets,
) || guarded_variable_feeds_sink_target_in_events(
catch_events,
guard_span,
sink_span,
guarded,
sink_targets,
) || guarded_variable_feeds_sink_target_in_events(
finally_events,
guard_span,
sink_span,
guarded,
sink_targets,
) {
return true;
}
}
_ => {}
}
}
false
}
fn guarded_variable_flows_into_receiver_before_sink(
events: &[FlowEvent],
guard_span: Span,
sink_span: Span,
guarded: &AHashSet<String>,
receiver_targets: &AHashSet<String>,
receiver_mutation_targets: &[RuleTarget],
) -> bool {
for event in events {
match event {
FlowEvent::Call {
span,
name,
receiver,
receiver_types,
args,
..
} if span.file == sink_span.file
&& guard_span.end <= span.start
&& span.start <= sink_span.start =>
{
let Some(receiver) = receiver.as_deref().and_then(clean_overwrite_target_key) else {
continue;
};
if !receiver_targets.contains(&receiver)
|| !receiver_mutation_targets
.iter()
.any(|target| rule_target_matches_call(name, receiver_types, target))
{
continue;
}
if args.iter().any(|arg| {
call_arg_target_keys(arg)
.into_iter()
.any(|key| guarded.contains(&key))
}) {
return true;
}
}
FlowEvent::Branch {
then_events,
else_events,
..
} => {
if guarded_variable_flows_into_receiver_before_sink(
then_events,
guard_span,
sink_span,
guarded,
receiver_targets,
receiver_mutation_targets,
) || guarded_variable_flows_into_receiver_before_sink(
else_events,
guard_span,
sink_span,
guarded,
receiver_targets,
receiver_mutation_targets,
) {
return true;
}
}
FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
if guarded_variable_flows_into_receiver_before_sink(
body,
guard_span,
sink_span,
guarded,
receiver_targets,
receiver_mutation_targets,
) {
return true;
}
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
if guarded_variable_flows_into_receiver_before_sink(
body,
guard_span,
sink_span,
guarded,
receiver_targets,
receiver_mutation_targets,
) || guarded_variable_flows_into_receiver_before_sink(
catch_events,
guard_span,
sink_span,
guarded,
receiver_targets,
receiver_mutation_targets,
) || guarded_variable_flows_into_receiver_before_sink(
finally_events,
guard_span,
sink_span,
guarded,
receiver_targets,
receiver_mutation_targets,
) {
return true;
}
}
_ => {}
}
}
false
}
fn post_sink_path_construction_containment_allowed(
metadata: &RulepackMetadata,
sanitizer_rule: Option<&Rule>,
sink_rule: &Rule,
san: &RuleMatch,
snk: &RuleMatch,
) -> bool {
if !sanitizer_credits_sink_tag(
metadata,
sanitizer_rule.and_then(|rule| rule.tag.as_deref()),
sink_rule.tag.as_deref(),
) || san.span.file != snk.span.file
|| match_precedes_or_same(san, snk)
{
return false;
}
sink_rule
.analysis_semantics
.as_ref()
.and_then(|semantics| semantics.post_sink_policy)
== Some(PostSinkPolicy::PathConstructionContainment)
}
fn match_precedes_or_same(a: &RuleMatch, b: &RuleMatch) -> bool {
a.line < b.line || (a.line == b.line && a.column <= b.column)
}
fn tainted_call_matches_sink(call: &TaintedCall, sink: &RuleMatch) -> bool {
spans_overlap(call.call_span, sink.span)
}
fn rule_match_kind_is_param(pack: &Rulepack, rule_id: &str) -> bool {
pack.find_rule_by_id(rule_id)
.map(|r| matches!(r.match_spec.kind, crate::rule::MatchKind::Param))
.unwrap_or(false)
}
fn source_anchor_for_rule_match(pack: &Rulepack, src: &RuleMatch) -> Option<Span> {
if src.origin != MatchOrigin::Rulepack || rule_match_kind_is_param(pack, &src.rule_id) {
None
} else {
Some(src.span)
}
}
fn find_call_event_at(events: &[FlowEvent], target: bonsai_common::Span) -> Option<&FlowEvent> {
fn rank(span: Span, target: Span) -> Option<(u8, u64, u64)> {
if span.file != target.file {
return None;
}
if span == target {
return Some((0, 0, 0));
}
if span_contains(span, target) {
return Some((1, span.len(), 0));
}
if spans_overlap(span, target) {
let overlap_start = span.start.max(target.start);
let overlap_end = span.end.min(target.end);
let overlap = overlap_end.saturating_sub(overlap_start);
return Some((2, u64::MAX.saturating_sub(overlap), span.len()));
}
None
}
fn collect_best<'a>(
events: &'a [FlowEvent],
target: Span,
best: &mut Option<((u8, u64, u64), &'a FlowEvent)>,
) {
for event in events {
if let FlowEvent::Call { span, .. } = event {
if let Some(candidate_rank) = rank(*span, target) {
if best
.as_ref()
.is_none_or(|(best_rank, _)| candidate_rank < *best_rank)
{
*best = Some((candidate_rank, event));
}
}
}
match event {
FlowEvent::Branch {
then_events,
else_events,
..
} => {
collect_best(then_events, target, best);
collect_best(else_events, target, best);
}
FlowEvent::Try {
body,
catch_events,
finally_events,
..
} => {
collect_best(body, target, best);
collect_best(catch_events, target, best);
collect_best(finally_events, target, best);
}
FlowEvent::Loop { body, .. }
| FlowEvent::Defer { body, .. }
| FlowEvent::Using { body, .. } => collect_best(body, target, best),
_ => {}
}
}
}
let mut best = None;
collect_best(events, target, &mut best);
best.map(|(_, event)| event)
}
fn output_arg_names_for_match(pack: &Rulepack, src: &RuleMatch, decl: &bonsai_lang_api::Decl) -> Vec<String> {
let Some(rule) = pack.find_rule_by_id(&src.rule_id) else {
return Vec::new();
};
let Some(semantics) = rule.taint_semantics.as_ref() else {
return Vec::new();
};
if semantics.source_output_args.is_empty() {
return Vec::new();
}
let Some(FlowEvent::Call { args, .. }) = find_call_event_at(&decl.flow_events, src.span) else {
return Vec::new();
};
let mut out = Vec::new();
for &idx in &semantics.source_output_args {
let Some(arg) = args.get(idx) else { continue };
if let Some(name) = arg.place.as_deref() {
if !name.is_empty() {
out.push(name.to_string());
}
}
for n in &arg.source_names {
if !n.is_empty() {
out.push(n.clone());
}
}
}
out.sort();
out.dedup();
out
}
fn source_seed_set(pack: &Rulepack, src: &RuleMatch, decl: &bonsai_lang_api::Decl) -> TokenSet {
let mut out = TokenSet::default();
let is_inferred = src.origin != MatchOrigin::Rulepack;
let rule = pack.find_rule_by_id(&src.rule_id);
let is_param_rule = rule.is_some_and(|rule| rule.match_spec.kind == MatchKind::Param);
let source_output_args = rule
.and_then(|rule| rule.taint_semantics.as_ref())
.map(|semantics| semantics.source_output_args.as_slice())
.unwrap_or(&[]);
let source_callback_args = rule
.and_then(|rule| rule.taint_semantics.as_ref())
.map(|semantics| semantics.source_callback_args.as_slice())
.unwrap_or(&[]);
if is_inferred || is_param_rule {
insert_taint_aliases(&mut out, &src.match_text);
insert_descendant_taint_aliases(&mut out, &src.match_text);
}
let allow_text_only_source_match = is_inferred || is_param_rule;
collect_source_seed_targets(
&decl.flow_events,
src,
source_output_args,
source_callback_args,
allow_text_only_source_match,
&mut out,
);
if out.is_empty() && (is_inferred || is_param_rule) {
insert_taint_aliases(&mut out, &src.match_text);
}
out
}
fn span_contains(outer: Span, inner: Span) -> bool {
outer.file == inner.file && outer.start <= inner.start && inner.end <= outer.end
}
fn spans_overlap(a: Span, b: Span) -> bool {
a.file == b.file && a.start < b.end && b.start < a.end
}
fn demote_severity_one_tier(sev: Severity) -> Severity {
match sev {
Severity::Critical => Severity::High,
Severity::High => Severity::Medium,
Severity::Medium => Severity::Low,
Severity::Low | Severity::Info => Severity::Info,
}
}
fn cap_local_trust_severity(sev: Severity) -> Severity {
match sev {
Severity::Critical | Severity::High | Severity::Medium => Severity::Medium,
Severity::Low => Severity::Low,
Severity::Info => Severity::Info,
}
}
fn source_sink_flow_emission_key(
idx: usize,
snk: &RuleMatch,
call: &TaintedCall,
) -> (usize, String, u32, u64, u64, Option<u64>) {
(
idx,
snk.rule_id.clone(),
snk.span.file.raw(),
snk.span.start,
snk.span.end,
call.parent_trace_id,
)
}
fn precision_label(precision: Precision) -> &'static str {
match precision {
Precision::Exact => "exact",
Precision::Narrowed => "narrowed",
Precision::OverApproximate => "over-approximate",
Precision::Unknown => "unknown",
}
}
fn static_evidence_label(max_precision: Option<Precision>) -> &'static str {
match max_precision {
Some(Precision::Exact) => "exact",
_ => "exact+narrowed",
}
}
fn precision_from_label(label: &str) -> Option<Precision> {
match label {
"exact" => Some(Precision::Exact),
"narrowed" => Some(Precision::Narrowed),
"over-approximate" | "over_approximate" => Some(Precision::OverApproximate),
"unknown" => Some(Precision::Unknown),
_ => None,
}
}
fn finding_precision_within(label: &str, max_precision: Precision) -> bool {
precision_from_label(label)
.is_some_and(|precision| precision.is_proven_static_evidence() && precision <= max_precision)
}
fn flow_id_for_taint_path(chain_names: &[String], taint_path: &[TaintPropagationStep]) -> String {
let tokens = taint_path_identity_tokens(chain_names, taint_path);
bonsai_inspect::compute_flow_id(&tokens)
}
#[cfg(test)]
fn flow_id_for_chain_names(chain_names: &[String]) -> String {
bonsai_inspect::compute_flow_id(chain_names)
}
fn group_id_for_taint_path(chain_names: &[String], taint_path: &[TaintPropagationStep]) -> String {
let tokens = taint_path_identity_tokens(chain_names, taint_path);
if tokens.len() > 1 {
bonsai_inspect::compute_group_id(&tokens[1..])
} else {
bonsai_inspect::compute_group_id(&tokens)
}
}
#[cfg(test)]
fn group_id_for_chain_names(chain_names: &[String]) -> String {
bonsai_inspect::compute_group_id(group_tail_for_chain_names(chain_names))
}
#[cfg(test)]
fn group_tail_for_chain_names(chain_names: &[String]) -> &[String] {
if chain_names.len() > 1 {
&chain_names[1..]
} else {
chain_names
}
}
fn taint_path_identity_tokens(chain_names: &[String], taint_path: &[TaintPropagationStep]) -> Vec<String> {
if taint_path.is_empty() {
return chain_names.to_vec();
}
let mut tokens = Vec::new();
for step in taint_path {
tokens.push(format!(
"{}\0{}\0{}:{}:{}",
step.caller, step.callee, step.file, step.line, step.column
));
for arg in &step.tainted_args {
tokens.push(format!(
"arg:{}\0{}\0{}",
arg.index, arg.value_text, arg.param_name
));
}
}
tokens
}
fn compute_status(
metadata: &RulepackMetadata,
sanitizers: &[FindingMatch],
sink_tag: Option<&str>,
) -> FindingStatus {
if sanitizers.is_empty() {
return FindingStatus::Unsanitized;
}
let mut any_credit = false;
let mut any_real_sanitizer_fired = false;
for sanitizer in sanitizers {
if sanitizer_credits_sink_tag(metadata, sanitizer.tag.as_deref(), sink_tag) {
any_credit = true;
break;
}
if let Some(tag) = sanitizer.tag.as_deref() {
if !sanitizer_tag_is_recognized_non_crediting(metadata, tag) {
any_real_sanitizer_fired = true;
}
}
}
if any_credit {
FindingStatus::Sanitized
} else if any_real_sanitizer_fired {
FindingStatus::WrongContext
} else {
FindingStatus::Unsanitized
}
}
fn rule_kind_str(kind: RuleKind) -> &'static str {
match kind {
RuleKind::Source => "source",
RuleKind::Sink => "sink",
RuleKind::Sanitizer => "sanitizer",
RuleKind::Typing => "typing",
}
}
#[must_use]
pub fn rule_family(id: &str) -> &str {
let mut it = id.splitn(3, '.');
let _lang = it.next();
match (it.next(), it.next()) {
(Some(fam), _) => fam,
_ => id,
}
}
#[must_use]
fn rule_matches_category(pack: &Rulepack, rule: &Rule, category: &str) -> bool {
if rule.tag.as_deref() == Some(category) {
return true;
}
let raw_family = rule_family(&rule.id);
if pack.normalized_sink_family(raw_family) == category {
return true;
}
raw_family == category
}
fn clean_output_overwrites_from_rulepack_for_languages(
pack: &Rulepack,
languages: &AHashSet<String>,
) -> Vec<CleanOutputOverwrite> {
let mut out: Vec<_> = pack
.all_rules()
.into_iter()
.filter(|rule| {
rule.enabled && rule.kind == RuleKind::Sanitizer && languages.contains(rule.language.as_str())
})
.filter_map(|rule| {
let semantics = rule.taint_semantics.as_ref()?.clean_output_overwrite.as_ref()?;
let callee = rule
.match_spec
.callee
.as_ref()
.and_then(semantic_transfer_callee)?;
Some(CleanOutputOverwrite {
callee,
output_arg_index: semantics.output_arg_index,
value_start_arg_index: semantics.value_start_arg_index,
})
})
.collect();
sort_clean_output_overwrites(&mut out);
out
}
fn idg_transfer_options_from_rulepack_shapes(
overwrites: &[CleanOutputOverwrite],
source_outputs: &[SourceOutputArgs],
source_callbacks: &[SourceCallbackArgs],
output_arg_flows: &[OutputArgFlow],
receiver_state_propagations: &[ReceiverStatePropagation],
) -> bonsai_idg::TransferOptions {
bonsai_idg::TransferOptions {
clean_output_overwrites: overwrites
.iter()
.map(|shape| bonsai_idg::CleanOutputOverwriteSpec {
callee: shape.callee.clone(),
output_arg_index: shape.output_arg_index,
value_start_arg_index: shape.value_start_arg_index,
})
.collect(),
source_output_args: source_outputs
.iter()
.map(|shape| bonsai_idg::SourceOutputArgSpec {
callee: shape.callee.clone(),
output_arg_indices: shape.output_arg_indices.clone(),
})
.collect(),
source_callback_args: source_callbacks
.iter()
.map(|shape| bonsai_idg::SourceCallbackArgSpec {
callee: shape.callee.clone(),
callback_arg_index: shape.callback_arg_index,
source_param_indices: shape.source_param_indices.clone(),
})
.collect(),
call_result_passthroughs: Vec::new(),
output_arg_flows: output_arg_flows
.iter()
.map(|shape| bonsai_idg::OutputArgFlowSpec {
callee: shape.callee.clone(),
output_arg_index: shape.output_arg_index,
value_arg_indices: shape.value_arg_indices.clone(),
value_start_arg_index: shape.value_start_arg_index,
})
.collect(),
receiver_state_propagations: receiver_state_propagations
.iter()
.map(|shape| bonsai_idg::ReceiverStatePropagationSpec {
method: shape.method.clone(),
receiver_type: shape.receiver_type.clone(),
resolved_call_sites: shape.resolved_call_sites.clone(),
})
.collect(),
include_diagnostic_field_flows: false,
include_receiver_method_propagation: false,
include_field_argument_forwarding: true,
symbolic_field_forwarding: false,
symbolic_field_languages: Vec::new(),
include_unresolved_call_result_passthrough: true,
include_unresolved_receiver_result_passthrough: false,
}
}
#[derive(Clone, Debug, Default)]
pub struct RulepackTaintTransfers {
pub receiver_state_propagations: Vec<ReceiverStatePropagation>,
pub call_result_passthroughs: Vec<CallResultPassthrough>,
pub output_arg_flows: Vec<OutputArgFlow>,
}
pub fn taint_transfers_from_rulepack(pack: &Rulepack) -> RulepackTaintTransfers {
RulepackTaintTransfers {
receiver_state_propagations: receiver_state_propagations_from_rulepack(pack),
call_result_passthroughs: call_result_passthroughs_from_rulepack(pack),
output_arg_flows: output_arg_flows_from_rulepack(pack),
}
}
pub fn seed_idg_service_for_rulepack(ws: &Workspace, pack: &Rulepack) -> Arc<bonsai_idg::IdgQueryService> {
let languages = workspace_languages(ws);
let rulepack_typing = crate::matcher::build_rulepack_typing(&pack.all_rules());
let overwrites = clean_output_overwrites_from_rulepack_for_languages(pack, &languages);
let source_outputs = source_output_args_from_rulepack_for_languages(pack, &languages);
let source_callbacks = source_callback_args_from_rulepack_for_languages(pack, &languages);
let output_arg_flows = output_arg_flows_from_rulepack_for_languages(pack, &languages);
let receiver_state_propagations = compiled_receiver_state_propagations_for_languages(
ws,
pack,
&languages,
&rulepack_typing,
None,
|| {},
);
let mut options = idg_transfer_options_from_rulepack_shapes(
&overwrites,
&source_outputs,
&source_callbacks,
&output_arg_flows,
&receiver_state_propagations,
);
options.call_result_passthroughs = idg_call_result_passthrough_specs(
&call_result_passthroughs_from_rulepack_for_languages(pack, &languages),
);
options.symbolic_field_languages = ws.db().complete_field_place_languages();
options.symbolic_field_forwarding = !options.symbolic_field_languages.is_empty();
ws.build_and_seed_idg_service_with_transfer_options(&options)
}
fn seed_idg_service_for_rulepack_for_files(
ws: &Workspace,
pack: &Rulepack,
languages: &AHashSet<String>,
receiver_state_propagations: &[ReceiverStatePropagation],
included_files: &[FileId],
included_funcs: &[FuncId],
call_graph: &bonsai_callgraph::ResolvedCallGraph,
) -> Arc<bonsai_idg::IdgQueryService> {
let overwrites = clean_output_overwrites_from_rulepack_for_languages(pack, languages);
let source_outputs = source_output_args_from_rulepack_for_languages(pack, languages);
let source_callbacks = source_callback_args_from_rulepack_for_languages(pack, languages);
let output_arg_flows = output_arg_flows_from_rulepack_for_languages(pack, languages);
let mut options = idg_transfer_options_from_rulepack_shapes(
&overwrites,
&source_outputs,
&source_callbacks,
&output_arg_flows,
receiver_state_propagations,
);
options.call_result_passthroughs = idg_call_result_passthrough_specs(
&call_result_passthroughs_from_rulepack_for_languages(pack, languages),
);
options.symbolic_field_languages = symbolic_field_languages(ws, included_files);
options.symbolic_field_forwarding = !options.symbolic_field_languages.is_empty();
bonsai_diagnostics::debug_log!(
"security-phase",
"semantic graph transfer options languages={} funcs={} receiver_method_propagation={} field_argument_forwarding={} symbolic_field_languages={}",
languages.len(),
included_funcs.len(),
options.include_receiver_method_propagation,
options.include_field_argument_forwarding,
options.symbolic_field_languages.len()
);
ws.build_and_seed_persisted_idg_service_with_transfer_options_for_files_and_call_graph(
&options,
included_files,
included_funcs,
call_graph,
)
}
fn symbolic_field_languages(ws: &Workspace, files: &[FileId]) -> Vec<String> {
let mut languages: Vec<String> = files
.iter()
.filter_map(|file| ws.db().adapter_for(*file))
.filter(|adapter| adapter.capabilities().field_places_complete)
.map(|adapter| adapter.language_id().as_str().to_string())
.collect();
languages.sort();
languages.dedup();
languages
}
fn source_output_args_from_rulepack_for_languages(
pack: &Rulepack,
languages: &AHashSet<String>,
) -> Vec<SourceOutputArgs> {
let mut out: Vec<_> = pack
.all_rules()
.into_iter()
.filter(|rule| {
rule.enabled && rule.kind == RuleKind::Source && languages.contains(rule.language.as_str())
})
.filter_map(|rule| {
let semantics = rule.taint_semantics.as_ref()?;
if semantics.source_output_args.is_empty() {
return None;
}
let callee = rule
.match_spec
.callee
.as_ref()
.and_then(semantic_transfer_callee)?;
let mut output_arg_indices = semantics.source_output_args.clone();
output_arg_indices.sort_unstable();
output_arg_indices.dedup();
Some(SourceOutputArgs {
callee,
output_arg_indices,
})
})
.collect();
sort_source_output_args(&mut out);
out
}
fn source_callback_args_from_rulepack_for_languages(
pack: &Rulepack,
languages: &AHashSet<String>,
) -> Vec<SourceCallbackArgs> {
let mut out = Vec::new();
for rule in pack.all_rules() {
if !rule.enabled || rule.kind != RuleKind::Source || !languages.contains(rule.language.as_str()) {
continue;
}
let Some(semantics) = rule.taint_semantics.as_ref() else {
continue;
};
if semantics.source_callback_args.is_empty() {
continue;
}
let Some(callee) = rule.match_spec.callee.as_ref().and_then(semantic_transfer_callee) else {
continue;
};
for callback in &semantics.source_callback_args {
let mut source_param_indices = callback.source_param_indices.clone();
source_param_indices.sort_unstable();
source_param_indices.dedup();
out.push(SourceCallbackArgs {
callee: callee.clone(),
callback_arg_index: callback.callback_arg_index,
source_param_indices,
});
}
}
sort_source_callback_args(&mut out);
out
}
fn call_result_passthroughs_from_rulepack(pack: &Rulepack) -> Vec<CallResultPassthrough> {
call_result_passthroughs_from_rules(pack.all_rules())
}
fn call_result_passthroughs_from_rulepack_for_languages(
pack: &Rulepack,
languages: &AHashSet<String>,
) -> Vec<CallResultPassthrough> {
call_result_passthroughs_from_rules(
pack.all_rules()
.into_iter()
.filter(|rule| languages.contains(rule.language.as_str())),
)
}
fn call_result_passthroughs_from_rules<'a>(
rules: impl IntoIterator<Item = &'a Rule>,
) -> Vec<CallResultPassthrough> {
let mut out: Vec<_> = rules
.into_iter()
.filter(|rule| rule.enabled && matches!(rule.kind, RuleKind::Sanitizer | RuleKind::Typing))
.filter_map(|rule| {
let semantics = rule.taint_semantics.as_ref()?;
if semantics.call_result_passthrough_args.is_empty()
&& !semantics.call_result_passthrough_receiver
{
return None;
}
let target = rule.match_spec.callee.as_ref()?;
let (callee, receiver_type) = if rule.kind == RuleKind::Typing {
if let Some(attribute) = target.attribute.as_ref().filter(|parts| parts.len() >= 2) {
(
attribute.last()?.clone(),
Some(attribute[..attribute.len() - 1].join(".")),
)
} else {
(semantic_transfer_callee(target)?, None)
}
} else {
(semantic_transfer_callee(target)?, None)
};
let mut input_arg_indices = semantics.call_result_passthrough_args.clone();
input_arg_indices.sort_unstable();
input_arg_indices.dedup();
Some(CallResultPassthrough {
callee,
receiver_type,
input_arg_indices,
input_receiver: semantics.call_result_passthrough_receiver,
})
})
.collect();
sort_call_result_passthroughs(&mut out);
out
}
fn idg_call_result_passthrough_specs(
passthroughs: &[CallResultPassthrough],
) -> Vec<bonsai_idg::CallResultPassthroughSpec> {
passthroughs
.iter()
.map(|passthrough| bonsai_idg::CallResultPassthroughSpec {
callee: passthrough.callee.clone(),
receiver_type: passthrough.receiver_type.clone(),
input_arg_indices: passthrough.input_arg_indices.clone(),
input_receiver: passthrough.input_receiver,
})
.collect()
}
fn output_arg_flows_from_rulepack(pack: &Rulepack) -> Vec<OutputArgFlow> {
output_arg_flows_from_rules(pack.all_rules())
}
fn output_arg_flows_from_rulepack_for_languages(
pack: &Rulepack,
languages: &AHashSet<String>,
) -> Vec<OutputArgFlow> {
output_arg_flows_from_rules(
pack.all_rules()
.into_iter()
.filter(|rule| languages.contains(rule.language.as_str())),
)
}
fn output_arg_flows_from_rules<'a>(rules: impl IntoIterator<Item = &'a Rule>) -> Vec<OutputArgFlow> {
let mut out: Vec<_> = rules
.into_iter()
.filter(|rule| rule.enabled)
.flat_map(|rule| {
let callee = rule.match_spec.callee.as_ref().and_then(semantic_transfer_callee);
let Some(callee) = callee else {
return Vec::new();
};
rule.taint_semantics
.as_ref()
.map(|semantics| {
semantics
.output_arg_flows
.iter()
.map(|flow| OutputArgFlow {
callee: callee.clone(),
output_arg_index: flow.output_arg_index,
value_start_arg_index: flow.value_start_arg_index,
value_arg_indices: {
let mut indices = flow.value_arg_indices.clone();
indices.sort_unstable();
indices.dedup();
indices
},
})
.collect::<Vec<_>>()
})
.unwrap_or_default()
})
.collect();
sort_output_arg_flows(&mut out);
out
}
fn semantic_transfer_callee(target: &RuleTarget) -> Option<String> {
target
.name
.clone()
.or_else(|| target.attribute.as_ref().map(|parts| parts.join(".")))
.or_else(|| target.regex.as_ref().map(|regex| format!("regex:{regex}")))
}
fn receiver_state_propagations_from_rulepack(pack: &Rulepack) -> Vec<ReceiverStatePropagation> {
receiver_state_propagations_from_rules(pack.all_rules(), &AHashMap::new())
}
fn compiled_receiver_state_propagations_for_languages<F>(
ws: &Workspace,
pack: &Rulepack,
languages: &AHashSet<String>,
factory: &Arc<crate::matcher::RulepackTyping>,
files: Option<&[FileId]>,
on_file_done: F,
) -> Vec<ReceiverStatePropagation>
where
F: FnMut(),
{
let rules: Vec<&Rule> = pack
.all_rules()
.into_iter()
.filter(|rule| {
rule.enabled
&& languages.contains(rule.language.as_str())
&& matches!(rule.kind, RuleKind::Sink | RuleKind::Typing)
&& (rule.kind == RuleKind::Typing || rule_has_taint_predicate(rule))
&& rule
.taint_semantics
.as_ref()
.is_some_and(|semantics| semantics.taint_receiver_from_args)
})
.collect();
if rules.is_empty() {
return Vec::new();
}
let all_files;
let files = if let Some(files) = files {
files
} else {
all_files = {
let mut files = ws.vfs().all_files();
files.sort_by_key(|file| file.raw());
files
};
&all_files
};
let matches = crate::matcher::match_rules_against_facts_for_sink_inventory_with_progress_on_files(
ws,
&rules,
files,
factory,
on_file_done,
);
let mut sites_by_rule: AHashMap<String, Vec<Span>> = AHashMap::new();
for rule_match in matches {
sites_by_rule
.entry(rule_match.rule_id)
.or_default()
.push(rule_match.span);
}
for sites in sites_by_rule.values_mut() {
sites.sort();
sites.dedup();
}
receiver_state_propagations_from_rules(rules, &sites_by_rule)
}
fn receiver_state_propagations_from_rules<'a>(
rules: impl IntoIterator<Item = &'a Rule>,
sites_by_rule: &AHashMap<String, Vec<Span>>,
) -> Vec<ReceiverStatePropagation> {
let mut out: Vec<_> = rules
.into_iter()
.filter(|rule| {
rule.enabled
&& matches!(rule.kind, RuleKind::Sink | RuleKind::Typing)
&& (rule.kind == RuleKind::Typing || rule_has_taint_predicate(rule))
&& rule
.taint_semantics
.as_ref()
.is_some_and(|semantics| semantics.taint_receiver_from_args)
})
.filter_map(|rule| {
receiver_state_propagation_from_rule(rule).map(|mut propagation| {
propagation.resolved_call_sites = sites_by_rule.get(&rule.id).cloned().unwrap_or_default();
propagation
})
})
.collect();
sort_receiver_state_propagations(&mut out);
out
}
fn sort_clean_output_overwrites(items: &mut Vec<CleanOutputOverwrite>) {
items.sort_by(|a, b| {
(&a.callee, a.output_arg_index, a.value_start_arg_index).cmp(&(
&b.callee,
b.output_arg_index,
b.value_start_arg_index,
))
});
items.dedup();
}
fn sort_source_output_args(items: &mut Vec<SourceOutputArgs>) {
items.sort_by(|a, b| (&a.callee, &a.output_arg_indices).cmp(&(&b.callee, &b.output_arg_indices)));
items.dedup();
}
fn sort_source_callback_args(items: &mut Vec<SourceCallbackArgs>) {
items.sort_by(|a, b| {
(&a.callee, a.callback_arg_index, &a.source_param_indices).cmp(&(
&b.callee,
b.callback_arg_index,
&b.source_param_indices,
))
});
items.dedup();
}
fn sort_call_result_passthroughs(items: &mut Vec<CallResultPassthrough>) {
items.sort_by(|a, b| {
(
&a.callee,
&a.receiver_type,
&a.input_arg_indices,
a.input_receiver,
)
.cmp(&(
&b.callee,
&b.receiver_type,
&b.input_arg_indices,
b.input_receiver,
))
});
items.dedup();
}
fn sort_output_arg_flows(items: &mut Vec<OutputArgFlow>) {
items.sort_by(|a, b| {
(
&a.callee,
a.output_arg_index,
a.value_start_arg_index,
&a.value_arg_indices,
)
.cmp(&(
&b.callee,
b.output_arg_index,
b.value_start_arg_index,
&b.value_arg_indices,
))
});
items.dedup();
}
fn sort_receiver_state_propagations(items: &mut Vec<ReceiverStatePropagation>) {
items.sort_by(|a, b| {
(&a.method, &a.receiver_type, &a.resolved_call_sites).cmp(&(
&b.method,
&b.receiver_type,
&b.resolved_call_sites,
))
});
items.dedup();
}
fn receiver_state_propagation_from_rule(rule: &Rule) -> Option<ReceiverStatePropagation> {
let target = rule.match_spec.callee.as_ref()?;
let attribute = target.attribute.as_ref()?;
if attribute.len() < 2 {
return None;
}
let method = attribute.last()?.trim();
if method.is_empty() {
return None;
}
Some(ReceiverStatePropagation {
method: method.to_string(),
receiver_type: Some(attribute[..attribute.len() - 1].join(".")),
resolved_call_sites: Vec::new(),
})
}
pub fn select_pack_rules<'a>(pack: &'a Rulepack, options: &PackInventoryOptions) -> Vec<&'a Rule> {
let mut rules: Vec<&Rule> = pack
.all_rules()
.into_iter()
.filter(|rule| options.lang.as_deref().is_none_or(|lang| rule.language == lang))
.filter(|rule| options.kind.is_none_or(|kind| rule.kind == kind))
.filter(|rule| {
options
.severity
.is_none_or(|min| rule.severity.is_some_and(|severity| severity >= min))
})
.filter(|rule| {
options
.category
.as_deref()
.is_none_or(|category| rule_matches_category(pack, rule, category))
})
.collect();
rules.sort_by(|a, b| {
(
a.language.as_str(),
a.kind,
pack.normalized_sink_family(rule_family(&a.id)),
a.id.as_str(),
)
.cmp(&(
b.language.as_str(),
b.kind,
pack.normalized_sink_family(rule_family(&b.id)),
b.id.as_str(),
))
});
rules
}
#[cfg(test)]
#[path = "semantic_options_tests.rs"]
mod semantic_options_tests;
#[cfg(test)]
#[path = "source_lineage_tests.rs"]
mod source_lineage_tests;
#[cfg(test)]
#[path = "finding_completeness_tests.rs"]
mod finding_completeness_tests;
#[cfg(test)]
#[path = "match_attribution_tests.rs"]
mod match_attribution_tests;
#[cfg(test)]
#[path = "taint_path_tests.rs"]
mod taint_path_tests;
#[cfg(test)]
mod source_seed_tests {
use super::*;
fn service_from_segment(segment: bonsai_idg::segment::IdgSegment) -> bonsai_idg::IdgQueryService {
let mut workspace = bonsai_idg::IdgWorkspace::new();
workspace.register_segment(segment);
bonsai_idg::IdgQueryService::new(
std::sync::Arc::new(workspace),
std::sync::Arc::new(bonsai_index::GlobalIndex::new()),
)
}
fn source_decl(events: Vec<FlowEvent>) -> bonsai_lang_api::Decl {
let span = Span::new(FileId::new(1), 0, 100);
bonsai_lang_api::Decl {
symbol: SymbolId::new(1),
kind: DeclKind::Function,
name: "handle_request".to_string(),
qualified_name: None,
module_path: bonsai_lang_api::ModulePath::default(),
span,
name_span: span,
visibility: bonsai_lang_api::Visibility::Public,
parent: None,
body_span: Some(span),
flow_events: events,
has_implicit_returns: false,
params: Vec::new(),
param_annotations: Vec::new(),
param_default_calls: Vec::new(),
type_aliases: Vec::new(),
bases: Vec::new(),
receiver_param_index: None,
receiver_field_writes: Vec::new(),
receiver_field_initializers: Vec::new(),
implicit_receiver_names: Vec::new(),
receiver_state_sources: Vec::new(),
return_type: None,
is_variadic: false,
}
}
fn source_rule_match_at(span: Span) -> RuleMatch {
RuleMatch {
origin: MatchOrigin::Rulepack,
rule_id: "python.flask.request_args_get".to_string(),
language: "python".to_string(),
file: "app.py".to_string(),
line: 1,
column: 1,
span,
match_text: "request.args.get".to_string(),
enclosing_fn: Some("handle_request".to_string()),
}
}
#[test]
fn qualified_read_source_seeds_its_own_descendants_not_receiver() {
let mut seeds = TokenSet::default();
seed_descendant_aliases_for_qualified_source_reads(
&mut seeds,
&["req.query".to_string(), "req.query.theme".to_string()],
"req.query",
);
assert!(seeds.contains("req.query"));
assert!(seeds.contains("req.query.*"));
assert!(!seeds.contains("req.*"));
}
#[test]
fn concrete_call_source_seeds_only_overlapping_assignment_site() {
let file = FileId::new(1);
let events = vec![
FlowEvent::Assign {
span: Span::new(file, 10, 40),
target: "token".to_string(),
source_name: None,
source_call: Some("request.args.get".to_string()),
source_call_args: vec!["\"token\"".to_string()],
source_names: vec!["request.args".to_string()],
value_kind: Some(AssignValueKind::CallResult),
declares_new_binding: true,
},
FlowEvent::Assign {
span: Span::new(file, 50, 82),
target: "action".to_string(),
source_name: None,
source_call: Some("request.args.get".to_string()),
source_call_args: vec!["\"action\"".to_string()],
source_names: vec!["request.args".to_string()],
value_kind: Some(AssignValueKind::CallResult),
declares_new_binding: true,
},
];
let source = source_rule_match_at(Span::new(file, 20, 36));
let mut seeds = TokenSet::default();
collect_source_seed_targets(&events, &source, &[], &[], false, &mut seeds);
assert!(seeds.contains("token"));
assert!(!seeds.contains("action"));
}
#[test]
fn concrete_call_source_seeds_the_matched_call_result_not_siblings() {
let file = FileId::new(1);
let events = vec![
FlowEvent::Call {
span: Span::new(file, 10, 30),
name: "request.args.get".to_string(),
receiver: Some("request.args".to_string()),
receiver_types: Vec::new(),
call_kind: bonsai_lang_api::CallKind::Function,
args: vec![bonsai_lang_api::CallArg {
passing_mode: Default::default(),
span: Span::new(file, 27, 30),
name: None,
value_text: "\"token\"".to_string(),
place: None,
source_names: Vec::new(),
}],
},
FlowEvent::Call {
span: Span::new(file, 50, 72),
name: "other.args.get".to_string(),
receiver: Some("other.args".to_string()),
receiver_types: Vec::new(),
call_kind: bonsai_lang_api::CallKind::Function,
args: Vec::new(),
},
];
let source = source_rule_match_at(Span::new(file, 10, 30));
let mut seeds = TokenSet::default();
collect_source_seed_targets(&events, &source, &[], &[], false, &mut seeds);
assert!(seeds.contains("request.args.get"));
assert!(!seeds.contains("other.args.get"));
}
#[test]
fn concrete_source_without_structured_match_does_not_fallback_to_rule_text() {
let file = FileId::new(1);
let decl = source_decl(vec![FlowEvent::Call {
span: Span::new(file, 50, 72),
name: "other.args.get".to_string(),
receiver: Some("other.args".to_string()),
receiver_types: Vec::new(),
call_kind: bonsai_lang_api::CallKind::Function,
args: Vec::new(),
}]);
let source = source_rule_match_at(Span::new(file, 10, 30));
let seeds = source_seed_set(&Rulepack::default(), &source, &decl);
assert!(
seeds.is_empty(),
"concrete expression sources must use their span/structured event evidence, not broad rule-text fallback"
);
}
#[test]
fn anchored_call_return_seed_nodes_do_not_include_same_name_reads_or_writes() {
let func = FuncId::new(9);
let anchor = Span::new(FileId::new(0), 10, 20);
let later = Span::new(FileId::new(0), 40, 60);
let mut segment = bonsai_idg::segment::IdgSegment::new();
let source_name = segment.strings.intern("request.args.get");
let call_ret = segment.intern_place(bonsai_idg::Place::CallRet {
site: bonsai_idg::CallSiteId(anchor),
});
let same_name_read = segment.intern_place(bonsai_idg::Place::Read {
name: source_name,
path: Vec::new().into(),
});
let same_name_write = segment.intern_place(bonsai_idg::Place::write(source_name, later));
segment.intern_node(func, call_ret);
segment.intern_node(func, same_name_read);
segment.intern_node(func, same_name_write);
segment.record_func(func);
let service = service_from_segment(segment);
let global = bonsai_index::GlobalIndex::new();
let seeds = TokenSet::from_iter(["request.args.get".to_string()]);
let nodes = compose_idg_seed_nodes(
IdgSeedRequest::rule_match(func, &seeds, Some(anchor), &[]),
&global,
&service,
);
let ret_ws = service.call_ret_node_at_site(func, anchor).expect("ret node");
let same_name_nodes = service.read_or_write_nodes_for_names(func, &["request.args.get".to_string()]);
assert!(nodes.contains(&ret_ws), "anchor CallRet remains a source seed");
assert!(
same_name_nodes.iter().all(|node| !nodes.contains(node)),
"anchored call-return sources must not widen to same-named reads/writes elsewhere in the function"
);
}
}
pub fn tree_file_rel(pack: &Rulepack, rule: &Rule) -> String {
let kind_dir = format!("{}s", rule_kind_str(rule.kind));
let base = pack.root.join("langs").join(&rule.language).join(kind_dir);
let path = Path::new(&rule.source_path);
path.strip_prefix(&base)
.ok()
.and_then(|rel| rel.to_str())
.map(std::borrow::ToOwned::to_owned)
.or_else(|| {
path.file_name()
.and_then(|name| name.to_str())
.map(std::borrow::ToOwned::to_owned)
})
.unwrap_or_else(|| short_file(&rule.source_path))
}
fn tree_file_path(pack: &Rulepack, lang: &str, kind: &str, rel_file: &str) -> String {
let root_name = pack
.root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("security-patterns");
format!("{root_name}/langs/{lang}/{}s/{rel_file}", kind)
}
fn short_file(path: &str) -> String {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(path)
.to_string()
}
#[cfg(test)]
mod compute_status_tests;