use crate::matcher::RuleMatch;
use crate::rule::{MatchOrigin, Rule, Severity};
use bonsai_hash::fnv1a_names64;
use serde::{Deserialize, Serialize};
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FindingStatus {
Unsanitized,
Sanitized,
WrongContext,
}
impl FindingStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Unsanitized => "unsanitized",
Self::Sanitized => "sanitized",
Self::WrongContext => "wrong-context",
}
}
fn merge_rank(self) -> u8 {
match self {
Self::Unsanitized => 2,
Self::WrongContext => 1,
Self::Sanitized => 0,
}
}
pub fn merge(self, other: Self) -> Self {
if self.merge_rank() >= other.merge_rank() {
self
} else {
other
}
}
}
impl Default for FindingStatus {
fn default() -> Self {
Self::Unsanitized
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FindingMatch {
#[serde(skip)]
pub origin: MatchOrigin,
pub rule_id: String,
pub file: String,
pub line: u32,
pub column: u32,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub enclosing_fn: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<Severity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trust: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub payload_types: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub tainted_args: Vec<TaintedArgInfo>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub sanitised_arg_indices: Vec<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct TaintedArgInfo {
pub index: usize,
pub value_text: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub place: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub source_names: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct TaintPropagationStep {
pub caller: String,
pub callee: String,
pub file: String,
pub line: u32,
pub column: u32,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub tainted_args: Vec<TaintPropagationArg>,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct TaintPropagationArg {
pub index: usize,
pub value_text: String,
pub param_name: String,
}
impl FindingMatch {
pub fn from_rule_match(rule_match: &RuleMatch, rule: &Rule) -> Self {
Self {
origin: rule_match.origin,
rule_id: rule_match.rule_id.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(),
tag: rule.tag.clone(),
severity: rule.severity,
category: rule.category.clone(),
trust: rule.trust.map(|trust_class| trust_class.as_str().to_string()),
payload_types: rule
.payload_types
.iter()
.map(|payload_type| payload_type.as_str().to_string())
.collect(),
tainted_args: Vec::new(),
sanitised_arg_indices: sanitised_indices_from_rule(rule),
}
}
#[must_use]
pub fn from_inferred(rule_match: &RuleMatch) -> Self {
Self {
origin: rule_match.origin,
rule_id: rule_match.rule_id.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(),
tag: Some("entry-point".to_string()),
severity: None,
category: Some("inferred".to_string()),
trust: Some("local".to_string()),
payload_types: Vec::new(),
tainted_args: Vec::new(),
sanitised_arg_indices: Vec::new(),
}
}
}
fn sanitised_indices_from_rule(rule: &Rule) -> Vec<u32> {
use crate::rule::ConstraintKind;
let mut indices: Vec<u32> = Vec::new();
for constraint in rule.constraints.0.iter() {
let index = match constraint {
ConstraintKind::ArgEquals { arg_equals } => Some(arg_equals.index),
ConstraintKind::ArgMatchesRegex { arg_matches_regex } => Some(arg_matches_regex.index),
ConstraintKind::ArgNotMatchesRegex {
arg_not_matches_regex,
} => Some(arg_not_matches_regex.index),
ConstraintKind::FormatArgIndex { format_arg_index } => Some(*format_arg_index),
ConstraintKind::SecondArgEquals { .. } => Some(1),
ConstraintKind::ArgLt { arg_lt } => Some(arg_lt.index),
ConstraintKind::ArgLe { arg_le } => Some(arg_le.index),
ConstraintKind::ArgGt { arg_gt } => Some(arg_gt.index),
ConstraintKind::ArgGe { arg_ge } => Some(arg_ge.index),
_ => None,
};
if let Some(idx) = index {
if !indices.contains(&idx) {
indices.push(idx);
}
}
}
indices
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Finding {
pub finding_id: String,
pub language: String,
pub source: FindingMatch,
pub sink: FindingMatch,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sanitizers_seen: Vec<FindingMatch>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub taint_transforms_seen: Vec<FindingMatch>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub representative_flow_id: Option<String>,
pub analysis_complete: bool,
pub analysis_incomplete_reasons: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub chain_display: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub taint_path: Vec<TaintPropagationStep>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub alternate_flows: Vec<AlternateTaintFlow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hops: Vec<crate::flow_evidence::FlowFunctionBody>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<Severity>,
pub precision: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cwe: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub owasp: Vec<String>,
#[serde(default)]
pub status: FindingStatus,
#[serde(default, skip_serializing_if = "is_false")]
pub from_test: bool,
}
impl Finding {
pub fn flows(&self) -> impl Iterator<Item = TaintFlowRef<'_>> {
std::iter::once(TaintFlowRef {
source: &self.source,
sink_tainted_args: &self.sink.tainted_args,
sanitizers_seen: &self.sanitizers_seen,
taint_transforms_seen: &self.taint_transforms_seen,
flow_id: self.representative_flow_id.as_deref(),
chain_display: &self.chain_display,
taint_path: &self.taint_path,
status: self.status,
precision: &self.precision,
})
.chain(self.alternate_flows.iter().map(|flow| TaintFlowRef {
source: &flow.source,
sink_tainted_args: &flow.sink_tainted_args,
sanitizers_seen: &flow.sanitizers_seen,
taint_transforms_seen: &flow.taint_transforms_seen,
flow_id: flow.flow_id.as_deref(),
chain_display: &flow.chain_display,
taint_path: &flow.taint_path,
status: flow.status,
precision: &flow.precision,
}))
}
pub fn flow_ids(&self) -> impl Iterator<Item = &str> {
self.flows().filter_map(|flow| flow.flow_id)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AlternateTaintFlow {
pub source: FindingMatch,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sink_tainted_args: Vec<TaintedArgInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sanitizers_seen: Vec<FindingMatch>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub taint_transforms_seen: Vec<FindingMatch>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flow_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub chain_display: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub taint_path: Vec<TaintPropagationStep>,
#[serde(default)]
pub status: FindingStatus,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub precision: String,
}
#[derive(Copy, Clone, Debug)]
pub struct TaintFlowRef<'a> {
pub source: &'a FindingMatch,
pub sink_tainted_args: &'a [TaintedArgInfo],
pub sanitizers_seen: &'a [FindingMatch],
pub taint_transforms_seen: &'a [FindingMatch],
pub flow_id: Option<&'a str>,
pub chain_display: &'a [String],
pub taint_path: &'a [TaintPropagationStep],
pub status: FindingStatus,
pub precision: &'a str,
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(value: &bool) -> bool {
!value
}
#[must_use]
pub(crate) fn path_is_test_file(path: &str, patterns: &[String]) -> bool {
let mut normalized = path.replace('\\', "/").to_ascii_lowercase();
if !normalized.starts_with('/') {
normalized.insert(0, '/');
}
let basename = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
patterns.iter().any(|pattern| {
let pattern = pattern.replace('\\', "/").to_ascii_lowercase();
if pattern.is_empty() {
return false;
}
if pattern.starts_with('/') && pattern.ends_with('/') {
normalized.contains(&pattern)
} else {
basename.ends_with(&pattern)
}
})
}
#[must_use]
pub fn compute_finding_id(source_id: &str, sink_id: &str, group_id: &str, language: &str) -> String {
let tokens = [
source_id.to_string(),
sink_id.to_string(),
group_id.to_string(),
language.to_string(),
];
format!("S:{:016x}", fnv1a_names64(&tokens))
}